mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 05:56:31 +08:00
use real api
This commit is contained in:
parent
fb4c111aec
commit
bea78ade6e
107
web/app/components/deployments/api-utils.ts
Normal file
107
web/app/components/deployments/api-utils.ts
Normal file
@ -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'
|
||||
}
|
||||
@ -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`)
|
||||
|
||||
@ -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<string, string>, item: CredentialRequirement) {
|
||||
return values[item.slot] || item.selectedCredentialId || item.options[0]?.id || ''
|
||||
}
|
||||
|
||||
function envVarValue(values: Record<string, string>, item: EnvVarRequirement) {
|
||||
return values[item.key] || item.selectedEnvVarId || item.options[0]?.id || ''
|
||||
}
|
||||
|
||||
type FieldProps = {
|
||||
@ -121,24 +179,24 @@ const LabeledSelect: FC<LabeledSelectProps> = ({ label, ...rest }) => (
|
||||
</div>
|
||||
)
|
||||
|
||||
type EnvironmentRowProps = { env: Environment }
|
||||
type EnvironmentRowProps = { env: EnvironmentOption }
|
||||
|
||||
const EnvironmentRow: FC<EnvironmentRowProps> = ({ env }) => (
|
||||
<div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="system-sm-semibold text-text-primary">{env.name}</span>
|
||||
<ModeBadge mode={env.mode} />
|
||||
<HealthBadge health={env.health} />
|
||||
<span className="system-sm-semibold text-text-primary">{environmentName(env)}</span>
|
||||
<ModeBadge mode={environmentMode(env)} />
|
||||
<HealthBadge health={environmentHealth(env)} />
|
||||
</div>
|
||||
<span className="system-xs-regular text-text-tertiary uppercase">{env.backend}</span>
|
||||
<span className="system-xs-regular text-text-tertiary uppercase">{env.type ?? 'env'}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
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<DeployFormProps> = ({
|
||||
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<string, typeof mockCredentials>()
|
||||
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<string>(
|
||||
() => 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<string>('')
|
||||
const [modelCredentials, setModelCredentials] = useState<Record<string, string>>(() => {
|
||||
const model: Record<string, string> = {}
|
||||
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<Record<string, string>>(() => {
|
||||
const plugin: Record<string, string> = {}
|
||||
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<Record<string, string>>(() => {
|
||||
const env: Record<string, string> = {}
|
||||
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<Record<string, string>>({})
|
||||
const [pluginCredentials, setPluginCredentials] = useState<Record<string, string>>({})
|
||||
const [envValues, setEnvValues] = useState<Record<string, string>>({})
|
||||
|
||||
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<DeployFormProps> = ({
|
||||
const handleDeploy = () => {
|
||||
if (!canDeploy)
|
||||
return
|
||||
const credentials: CredentialBinding[] = [
|
||||
...required.model.map<CredentialBinding>(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<CredentialBinding>(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<EnvVariable>(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<DeployFormProps> = ({
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 font-mono system-sm-semibold text-text-primary">{presetRelease.id}</span>
|
||||
<span className="shrink-0 font-mono system-sm-semibold text-text-primary">{releaseLabel(presetRelease)}</span>
|
||||
<span className="shrink-0 system-xs-regular text-text-tertiary">·</span>
|
||||
<span className="shrink-0 font-mono system-xs-regular text-text-tertiary">{presetRelease.gateCommitId}</span>
|
||||
<span className="shrink-0 font-mono system-xs-regular text-text-tertiary">{releaseCommit(presetRelease)}</span>
|
||||
{presetRelease.description && (
|
||||
<>
|
||||
<span className="shrink-0 system-xs-regular text-text-tertiary">·</span>
|
||||
@ -314,11 +340,11 @@ const DeployForm: FC<DeployFormProps> = ({
|
||||
? <EnvironmentRow env={lockedEnv} />
|
||||
: (
|
||||
<DeploymentSelect
|
||||
value={selectedEnvId}
|
||||
value={selectedEnvironmentId}
|
||||
onChange={setSelectedEnvId}
|
||||
options={environments.map(env => ({
|
||||
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<DeployFormProps> = ({
|
||||
{required.model.length > 0 && (
|
||||
<Field label={t('deployDrawer.modelCreds')}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{required.model.map((provider) => {
|
||||
const providerCreds = credentialsByProvider.get(provider) ?? []
|
||||
{required.model.map((item) => {
|
||||
return (
|
||||
<LabeledSelect
|
||||
key={provider}
|
||||
label={provider}
|
||||
value={modelCredentials[provider] ?? ''}
|
||||
onChange={v => 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<DeployFormProps> = ({
|
||||
{required.plugin.length > 0 && (
|
||||
<Field label={t('deployDrawer.pluginCreds')}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{required.plugin.map((provider) => {
|
||||
const providerCreds = credentialsByProvider.get(provider) ?? []
|
||||
{required.plugin.map((item) => {
|
||||
return (
|
||||
<LabeledSelect
|
||||
key={provider}
|
||||
label={provider}
|
||||
value={pluginCredentials[provider] ?? ''}
|
||||
onChange={v => 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<DeployFormProps> = ({
|
||||
<div className="flex flex-col gap-2">
|
||||
{required.envVars.map(v => (
|
||||
<div key={v.key} className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 system-xs-medium text-text-secondary">{v.key}</span>
|
||||
<div className="flex h-8 min-w-0 flex-1 items-center rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-2">
|
||||
<input
|
||||
type={v.type === 'secret' ? 'password' : 'text'}
|
||||
value={envValues[v.key] ?? ''}
|
||||
placeholder={v.type === 'secret' ? t('deployDrawer.secretPlaceholder') : t('deployDrawer.valuePlaceholder')}
|
||||
onChange={e => 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')}
|
||||
<span className="w-20 shrink-0 system-xs-medium text-text-secondary">{v.label}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<DeploymentSelect
|
||||
value={envVarValue(envValues, v)}
|
||||
onChange={next => setEnvValues(prev => ({ ...prev, [v.key]: next }))}
|
||||
options={v.options.map(option => ({ value: option.id, label: option.label }))}
|
||||
placeholder={t('deployDrawer.defaultSelect')}
|
||||
/>
|
||||
<span className="shrink-0 rounded-md border border-divider-subtle px-1.5 text-[10px] font-medium text-text-tertiary uppercase">
|
||||
{v.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -412,16 +432,15 @@ const DeployForm: FC<DeployFormProps> = ({
|
||||
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 (
|
||||
<Dialog
|
||||
@ -430,26 +449,25 @@ const DeployDrawer: FC = () => {
|
||||
>
|
||||
<DialogContent className="w-[560px] max-w-[90vw]">
|
||||
<DialogCloseButton />
|
||||
{!instance
|
||||
{!drawer.appId
|
||||
? <div className="p-4 text-text-tertiary">{t('deployDrawer.notFound')}</div>
|
||||
: (
|
||||
<DeployForm
|
||||
key={formKey}
|
||||
instance={instance}
|
||||
appId={drawer.appId}
|
||||
environments={environments}
|
||||
releases={releases}
|
||||
deployments={deployments}
|
||||
defaultReleaseId={defaultReleaseId}
|
||||
lockedEnvId={drawer.environmentId}
|
||||
presetReleaseId={drawer.releaseId}
|
||||
onCancel={closeDeployDrawer}
|
||||
onSubmit={({ environmentId, releaseId, releaseNote, credentials, envVariables }) =>
|
||||
onSubmit={({ environmentId, releaseId, releaseNote, bindings }) =>
|
||||
startDeploy({
|
||||
instanceId: instance.id,
|
||||
appId: drawer.appId!,
|
||||
environmentId,
|
||||
releaseId,
|
||||
releaseNote,
|
||||
credentials,
|
||||
envVariables,
|
||||
bindings,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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<NewInstanceCardProps> = ({ onOpen }) => {
|
||||
}
|
||||
|
||||
type InstanceCardProps = {
|
||||
instance: Instance
|
||||
app: AppInfo
|
||||
deployments: Deployment[]
|
||||
environments: Environment[]
|
||||
appData?: DeploymentAppData
|
||||
}
|
||||
|
||||
const InstanceCard: FC<InstanceCardProps> = ({ instance, app, deployments, environments }) => {
|
||||
const InstanceCard: FC<InstanceCardProps> = ({ app, appData }) => {
|
||||
const { t } = useTranslation('deployments')
|
||||
const router = useRouter()
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
@ -108,7 +108,7 @@ const InstanceCard: FC<InstanceCardProps> = ({ 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<HTMLElement>, action: () => void) => {
|
||||
e.stopPropagation()
|
||||
@ -117,17 +117,20 @@ const InstanceCard: FC<InstanceCardProps> = ({ 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<InstanceCardProps> = ({ 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<typeof deploymentStatus>) => {
|
||||
if (status === 'deploy_failed')
|
||||
return t('status.deployFailed')
|
||||
return t(`status.${status}`)
|
||||
@ -166,16 +169,16 @@ const InstanceCard: FC<InstanceCardProps> = ({ instance, app, deployments, envir
|
||||
<div className="flex min-w-[220px] flex-col gap-1">
|
||||
<div className="system-xs-medium text-text-secondary">{t('overview.deploymentStatus')}</div>
|
||||
{deployments.map((deployment) => {
|
||||
const env = envMap.get(deployment.environmentId)
|
||||
const status = deploymentStatus(deployment)
|
||||
return (
|
||||
<div key={deployment.id} className="flex min-w-0 items-center justify-between gap-3">
|
||||
<div key={environmentId(deployment.environment)} className="flex min-w-0 items-center justify-between gap-3">
|
||||
<span className="min-w-0 truncate text-text-tertiary">
|
||||
{env?.name ?? deployment.environmentId}
|
||||
{environmentName(deployment.environment)}
|
||||
</span>
|
||||
<span className="shrink-0 text-text-secondary">
|
||||
{statusLabel(deployment.status)}
|
||||
{statusLabel(status)}
|
||||
{' · '}
|
||||
{deployment.activeReleaseId}
|
||||
{releaseLabel(deployment.observedRuntime?.release || deployment.pendingDeployment?.release)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
@ -226,7 +229,7 @@ const InstanceCard: FC<InstanceCardProps> = ({ instance, app, deployments, envir
|
||||
</div>
|
||||
<div className="w-0 grow py-px">
|
||||
<div className="flex items-center text-sm leading-5 font-semibold text-text-secondary">
|
||||
<div className="truncate" title={instance.name}>{instance.name}</div>
|
||||
<div className="truncate" title={app.name}>{app.name}</div>
|
||||
</div>
|
||||
<div className="truncate text-[10px] leading-[18px] font-medium text-text-tertiary" title={appModeLabel}>
|
||||
{appModeLabel}
|
||||
@ -299,7 +302,7 @@ const InstanceCard: FC<InstanceCardProps> = ({ instance, app, deployments, envir
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[216px]">
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-3"
|
||||
onClick={e => handleMenuAction(e, () => openDeployDrawer({ instanceId: instance.id }))}
|
||||
onClick={e => handleMenuAction(e, () => openDeployDrawer({ appId: app.id }))}
|
||||
>
|
||||
<span className="system-sm-regular text-text-secondary">{t('card.menu.deploy')}</span>
|
||||
</DropdownMenuItem>
|
||||
@ -312,7 +315,7 @@ const InstanceCard: FC<InstanceCardProps> = ({ instance, app, deployments, envir
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-3"
|
||||
onClick={e => handleMenuAction(e, () => deleteInstance(instance.id))}
|
||||
onClick={e => handleMenuAction(e, () => deleteInstance(app.id))}
|
||||
>
|
||||
<span className="system-sm-regular text-text-destructive">{t('card.menu.delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
@ -391,9 +394,8 @@ const EnvironmentFilter: FC<EnvironmentFilterProps> = ({ 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<string, Deployment[]>()
|
||||
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<string, string>()
|
||||
appDataList.forEach((data) => {
|
||||
data.candidates.environmentOptions?.forEach((env) => {
|
||||
const id = environmentId(env)
|
||||
if (id)
|
||||
map.set(id, environmentName(env))
|
||||
})
|
||||
data.environmentDeployments.environmentDeployments?.forEach((row) => {
|
||||
const id = environmentId(row.environment)
|
||||
if (id)
|
||||
map.set(id, environmentName(row.environment))
|
||||
})
|
||||
})
|
||||
return map
|
||||
}, [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 = () => {
|
||||
</div>
|
||||
<div className="relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
<NewInstanceCard onOpen={openCreateInstanceModal} />
|
||||
{visibleInstances.map((instance) => {
|
||||
const app = appMap.get(instance.appId)
|
||||
if (!app)
|
||||
return null
|
||||
{visibleInstances.map((app) => {
|
||||
return (
|
||||
<InstanceCard
|
||||
key={instance.id}
|
||||
instance={instance}
|
||||
key={app.id}
|
||||
app={app}
|
||||
deployments={deploymentsByInstance.get(instance.id) ?? []}
|
||||
environments={environments}
|
||||
appData={appData[app.id]}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -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<CopyPillProps> = ({ label, value, prefix, className }) => {
|
||||
}
|
||||
|
||||
type ApiKeyRowProps = {
|
||||
label: string
|
||||
envName: string
|
||||
value: string
|
||||
apiKey: DeveloperAPIKeySummary
|
||||
onRevoke: () => void
|
||||
}
|
||||
|
||||
const ApiKeyRow: FC<ApiKeyRowProps> = ({ label, envName, value, onRevoke }) => {
|
||||
const ApiKeyRow: FC<ApiKeyRowProps> = ({ 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<ApiKeyRowProps> = ({ label, envName, value, onRevoke }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className="flex min-w-[140px] flex-col">
|
||||
<span className="system-sm-medium text-text-primary">{label}</span>
|
||||
<span className="system-sm-medium text-text-primary">{apiKey.name || apiKey.id}</span>
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{t('access.api.envPrefix', { env: envName })}
|
||||
{t('access.api.envPrefix', { env: apiKey.environmentName || apiKey.environmentId })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal pr-1 pl-2">
|
||||
<div className="min-w-0 flex-1 truncate font-mono text-[13px] font-medium text-text-secondary">
|
||||
{displayValue}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible(prev => !prev)}
|
||||
aria-label={visible ? t('access.hide') : t('access.show')}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
>
|
||||
<span className={cn(visible ? 'i-ri-eye-off-line' : 'i-ri-eye-line', 'h-3.5 w-3.5')} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
@ -156,11 +152,10 @@ const ApiKeyRow: FC<ApiKeyRowProps> = ({ label, envName, value, onRevoke }) => {
|
||||
const permissionIcon: Record<AccessPermissionKind, string> = {
|
||||
organization: 'i-ri-team-line',
|
||||
specific: 'i-ri-lock-line',
|
||||
external: 'i-ri-user-line',
|
||||
anyone: 'i-ri-global-line',
|
||||
}
|
||||
|
||||
const permissionOrder: AccessPermissionKind[] = ['organization', 'specific', 'external', 'anyone']
|
||||
const permissionOrder: AccessPermissionKind[] = ['organization', 'specific', 'anyone']
|
||||
|
||||
type PermissionPickerProps = {
|
||||
value: AccessPermissionKind
|
||||
@ -246,14 +241,15 @@ const EndpointRow: FC<EndpointRowProps> = ({ envName, label, value, openLabel })
|
||||
)
|
||||
|
||||
type ApiKeyGenerateMenuProps = {
|
||||
environments: Environment[]
|
||||
environments: ConsoleEnvironmentSummary[]
|
||||
onGenerate: (environmentId: string) => void
|
||||
}
|
||||
|
||||
const ApiKeyGenerateMenu: FC<ApiKeyGenerateMenuProps> = ({ environments, onGenerate }) => {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [open, setOpen] = useState(false)
|
||||
const disabled = environments.length === 0
|
||||
const selectableEnvironments = environments.filter(env => env.id)
|
||||
const disabled = selectableEnvironments.length === 0
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
||||
@ -272,17 +268,17 @@ const ApiKeyGenerateMenu: FC<ApiKeyGenerateMenuProps> = ({ environments, onGener
|
||||
</DropdownMenuTrigger>
|
||||
{open && !disabled && (
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[220px]">
|
||||
{environments.map(env => (
|
||||
{selectableEnvironments.map(env => (
|
||||
<DropdownMenuItem
|
||||
key={env.id}
|
||||
className="gap-2 px-3"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onGenerate(env.id)
|
||||
onGenerate(env.id!)
|
||||
}}
|
||||
>
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t('access.api.newKeyForEnv', { env: env.name })}
|
||||
{t('access.api.newKeyForEnv', { env: environmentName(env) })}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
@ -303,61 +299,52 @@ function getUrlOrigin(url?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueEnvironments(environments: (ConsoleEnvironmentSummary | undefined)[]) {
|
||||
return environments.filter((environment, index): environment is ConsoleEnvironmentSummary => {
|
||||
if (!environment?.id)
|
||||
return false
|
||||
return environments.findIndex(candidate => candidate?.id === environment.id) === index
|
||||
})
|
||||
}
|
||||
|
||||
type AccessTabProps = {
|
||||
instanceId: string
|
||||
}
|
||||
|
||||
const AccessTab: FC<AccessTabProps> = ({ instanceId }) => {
|
||||
const AccessTab: FC<AccessTabProps> = ({ instanceId: appId }) => {
|
||||
const { t } = useTranslation('deployments')
|
||||
const instances = useDeploymentsStore(state => state.instances)
|
||||
const environments = useDeploymentsStore(state => state.environments)
|
||||
const deployments = useDeploymentsStore(state => state.deployments)
|
||||
const apiKeys = useDeploymentsStore(state => state.apiKeys)
|
||||
const access = useDeploymentsStore(state => state.access)
|
||||
const appData = useDeploymentsStore(state => state.appData[appId])
|
||||
const createdApiToken = useDeploymentsStore(state => state.createdApiToken)
|
||||
const clearCreatedApiToken = useDeploymentsStore(state => state.clearCreatedApiToken)
|
||||
const generateApiKey = useDeploymentsStore(state => state.generateApiKey)
|
||||
const revokeApiKey = useDeploymentsStore(state => state.revokeApiKey)
|
||||
const toggleAccessMethod = useDeploymentsStore(state => state.toggleAccessMethod)
|
||||
const setEnvAccessPermission = useDeploymentsStore(state => state.setEnvAccessPermission)
|
||||
const toggleAccessChannel = useDeploymentsStore(state => state.toggleAccessChannel)
|
||||
const setEnvironmentAccessPolicy = useDeploymentsStore(state => state.setEnvironmentAccessPolicy)
|
||||
|
||||
const instance = instances.find(i => i.id === instanceId)
|
||||
const instanceAccess = access.find(a => a.instanceId === instanceId)
|
||||
|
||||
const instanceDeployments = useMemo(
|
||||
() => deployments.filter(d => d.instanceId === instanceId),
|
||||
[deployments, instanceId],
|
||||
const accessConfig = appData?.accessConfig
|
||||
const deploymentRows = useMemo(
|
||||
() => deployedRows(appData?.environmentDeployments.environmentDeployments),
|
||||
[appData?.environmentDeployments.environmentDeployments],
|
||||
)
|
||||
|
||||
const envMap = useMemo(
|
||||
() => new Map(environments.map(env => [env.id, env])),
|
||||
[environments],
|
||||
const policies = useMemo(
|
||||
() => accessConfig?.userAccess?.environmentPolicies ?? [],
|
||||
[accessConfig?.userAccess?.environmentPolicies],
|
||||
)
|
||||
|
||||
const instanceKeys = useMemo(
|
||||
() => apiKeys.filter(k => k.instanceId === instanceId),
|
||||
[apiKeys, instanceId],
|
||||
)
|
||||
|
||||
const deployedEnvs = useMemo(
|
||||
() => instanceDeployments
|
||||
.map(deployment => envMap.get(deployment.environmentId))
|
||||
.filter((env): env is Environment => !!env),
|
||||
[envMap, instanceDeployments],
|
||||
() => uniqueEnvironments([
|
||||
...deploymentRows.map(row => row.environment),
|
||||
...policies.map(policy => policy.environment),
|
||||
...(accessConfig?.webapp?.rows?.map(row => row.environment) ?? []),
|
||||
]),
|
||||
[accessConfig?.webapp?.rows, deploymentRows, policies],
|
||||
)
|
||||
|
||||
const permissionByEnv = useMemo(() => {
|
||||
const map = new Map<string, EnvAccessPermission>()
|
||||
instanceAccess?.envPermissions.forEach((p) => {
|
||||
map.set(p.environmentId, p)
|
||||
})
|
||||
return map
|
||||
}, [instanceAccess])
|
||||
|
||||
if (!instance || !instanceAccess)
|
||||
return null
|
||||
|
||||
const apiEnabled = instanceAccess.enabled.api
|
||||
const runEnabled = instanceAccess.enabled.runAccess
|
||||
const cliDomain = getUrlOrigin(instanceAccess.mcpUrl)
|
||||
const webappRows = accessConfig?.webapp?.rows?.filter(row => row.url) ?? []
|
||||
const apiKeys = accessConfig?.developerApi?.apiKeys ?? []
|
||||
const apiEnabled = accessConfig?.developerApi?.enabled ?? false
|
||||
const runEnabled = accessConfig?.webapp?.enabled ?? false
|
||||
const visibleCreatedApiToken = createdApiToken?.appId === appId ? createdApiToken : undefined
|
||||
const webappChannelVersion = policies.find(policy => policy.effectivePolicy?.channel === 'webapp')?.effectivePolicy?.version ?? 0
|
||||
const cliDomain = getUrlOrigin(accessConfig?.cli?.url)
|
||||
const cliDocsUrl = cliDomain ? `${cliDomain}/cli` : undefined
|
||||
|
||||
return (
|
||||
@ -375,8 +362,8 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId }) => {
|
||||
: (
|
||||
<div className="flex flex-col gap-3">
|
||||
{deployedEnvs.map((env) => {
|
||||
const current = permissionByEnv.get(env.id)
|
||||
const kind = current?.kind ?? 'organization'
|
||||
const policy = policies.find(item => item.environment?.id === env.id)?.effectivePolicy
|
||||
const kind = accessModeToPermissionKey(policy?.accessMode)
|
||||
return (
|
||||
<div
|
||||
key={env.id}
|
||||
@ -384,11 +371,18 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId }) => {
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<span className="min-w-[140px] system-xs-regular text-text-tertiary">
|
||||
{env.name}
|
||||
{environmentName(env)}
|
||||
</span>
|
||||
<PermissionPicker
|
||||
value={kind}
|
||||
onChange={next => setEnvAccessPermission(instanceId, env.id, next)}
|
||||
onChange={next => setEnvironmentAccessPolicy(
|
||||
appId,
|
||||
env.id!,
|
||||
policy?.channel ?? 'webapp',
|
||||
true,
|
||||
permissionKeyToAccessMode(next),
|
||||
policy?.version ?? 0,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{kind === 'specific' && (
|
||||
@ -409,7 +403,7 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId }) => {
|
||||
action={(
|
||||
<Switch
|
||||
checked={runEnabled}
|
||||
onCheckedChange={v => toggleAccessMethod(instanceId, 'runAccess', v)}
|
||||
onCheckedChange={v => toggleAccessChannel(appId, 'webapp', v, webappChannelVersion)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
@ -429,18 +423,22 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId }) => {
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t('access.runAccess.webappDesc')}
|
||||
</div>
|
||||
{instanceAccess.webappUrl && deployedEnvs.length > 0
|
||||
{webappRows.length > 0
|
||||
? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{deployedEnvs.map(env => (
|
||||
<EndpointRow
|
||||
key={`webapp-${env.id}`}
|
||||
envName={env.name}
|
||||
label={t('access.runAccess.urlLabel')}
|
||||
value={instanceAccess.webappUrl!}
|
||||
openLabel={t('access.runAccess.openWebapp')}
|
||||
/>
|
||||
))}
|
||||
{webappRows.map((row) => {
|
||||
const endpointUrl = webappUrl(row.url)
|
||||
|
||||
return (
|
||||
<EndpointRow
|
||||
key={`webapp-${row.environment?.id ?? row.url}`}
|
||||
envName={environmentName(row.environment)}
|
||||
label={t('access.runAccess.urlLabel')}
|
||||
value={endpointUrl}
|
||||
openLabel={t('access.runAccess.openWebapp')}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
@ -511,7 +509,7 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId }) => {
|
||||
action={(
|
||||
<Switch
|
||||
checked={apiEnabled}
|
||||
onCheckedChange={v => toggleAccessMethod(instanceId, 'api', v)}
|
||||
onCheckedChange={v => toggleAccessChannel(appId, 'api', v, 0)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
@ -529,10 +527,36 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId }) => {
|
||||
</div>
|
||||
<ApiKeyGenerateMenu
|
||||
environments={deployedEnvs}
|
||||
onGenerate={environmentId => generateApiKey(instanceId, environmentId)}
|
||||
onGenerate={environmentId => generateApiKey(appId, environmentId)}
|
||||
/>
|
||||
</div>
|
||||
{instanceKeys.length === 0
|
||||
{visibleCreatedApiToken && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-components-panel-border bg-components-panel-bg-blur p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="system-sm-medium text-text-primary">
|
||||
{t('access.api.newTokenTitle')}
|
||||
</span>
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{t('access.api.newTokenDescription')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearCreatedApiToken}
|
||||
aria-label={t('access.api.dismissToken')}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
>
|
||||
<span className="i-ri-close-line h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<CopyPill
|
||||
label={t('access.api.newTokenLabel')}
|
||||
value={visibleCreatedApiToken.token}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{apiKeys.length === 0
|
||||
? (
|
||||
<div className="rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-6 text-center system-sm-regular text-text-tertiary">
|
||||
{deployedEnvs.length === 0
|
||||
@ -542,15 +566,14 @@ const AccessTab: FC<AccessTabProps> = ({ instanceId }) => {
|
||||
)
|
||||
: (
|
||||
<div className="flex flex-col divide-y divide-divider-subtle">
|
||||
{instanceKeys.map((k) => {
|
||||
const env = envMap.get(k.environmentId)
|
||||
{apiKeys.map((apiKey) => {
|
||||
if (!apiKey.id || !apiKey.environmentId)
|
||||
return null
|
||||
return (
|
||||
<ApiKeyRow
|
||||
key={k.id}
|
||||
label={k.label}
|
||||
envName={env?.name ?? k.environmentId}
|
||||
value={k.value}
|
||||
onRevoke={() => revokeApiKey(k.id)}
|
||||
key={apiKey.id}
|
||||
apiKey={apiKey}
|
||||
onRevoke={() => revokeApiKey(appId, apiKey.environmentId!, apiKey.id!)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -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<InfoRowProps> = ({ label, value, mono, suffix }) => (
|
||||
)
|
||||
|
||||
type DeploymentPanelProps = {
|
||||
deployment: Deployment
|
||||
env: Environment
|
||||
release?: Release
|
||||
targetRelease?: Release
|
||||
failedRelease?: Release
|
||||
row: EnvironmentDeploymentRow
|
||||
}
|
||||
|
||||
const DeploymentPanel: FC<DeploymentPanelProps> = ({ deployment, env, release, targetRelease, failedRelease }) => {
|
||||
const DeploymentPanel: FC<DeploymentPanelProps> = ({ 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 (
|
||||
<div className="border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="system-sm-semibold text-text-primary">
|
||||
{env.name}
|
||||
{environmentName(env)}
|
||||
{' · '}
|
||||
{deployment.activeReleaseId}
|
||||
{releaseLabel(observed || pending)}
|
||||
</span>
|
||||
<ModeBadge mode={env.mode} />
|
||||
<HealthBadge health={env.health} />
|
||||
<ModeBadge mode={environmentMode(env)} />
|
||||
<HealthBadge health={environmentHealth(env)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-4 md:grid-cols-2">
|
||||
<InfoBlock title={t('deployTab.panel.instanceInfo')}>
|
||||
<InfoRow label={t('deployTab.panel.deploymentId')} value={deployment.id} mono />
|
||||
<InfoRow label={t('deployTab.panel.replicas')} value={deployment.replicas != null ? String(deployment.replicas) : '—'} />
|
||||
<InfoRow label={t('deployTab.panel.runtimeMode')} value={t(env.mode === 'isolated' ? 'mode.isolated' : 'mode.shared')} suffix={` / ${env.backend.toUpperCase()}`} />
|
||||
<InfoRow label={t('deployTab.panel.runtimeNote')} value={deployment.runtimeNote ?? '—'} />
|
||||
<InfoRow label={t('deployTab.panel.deploymentId')} value={deploymentId(row) || '—'} mono />
|
||||
<InfoRow label={t('deployTab.panel.replicas')} value={row.instance?.replicas != null ? String(row.instance.replicas) : '—'} />
|
||||
<InfoRow label={t('deployTab.panel.runtimeMode')} value={t(environmentMode(env) === 'isolated' ? 'mode.isolated' : 'mode.shared')} suffix={` / ${environmentBackend(env).toUpperCase()}`} />
|
||||
<InfoRow label={t('deployTab.panel.runtimeNote')} value={row.instance?.status ?? '—'} />
|
||||
</InfoBlock>
|
||||
|
||||
<InfoBlock title={t('deployTab.panel.releaseInfo')}>
|
||||
<InfoRow label={t('deployTab.panel.release')} value={release?.id ?? deployment.activeReleaseId} mono />
|
||||
<InfoRow label={t('deployTab.panel.commit')} value={release?.gateCommitId ?? '—'} mono />
|
||||
<InfoRow label={t('deployTab.panel.createdAt')} value={release?.createdAt ?? '—'} />
|
||||
{targetRelease && (
|
||||
<InfoRow label={t('deployTab.panel.targetRelease')} value={`${targetRelease.id} / ${targetRelease.gateCommitId}`} mono />
|
||||
<InfoRow label={t('deployTab.panel.release')} value={releaseLabel(observed || pending)} mono />
|
||||
<InfoRow label={t('deployTab.panel.commit')} value={releaseCommit(observed || pending)} mono />
|
||||
<InfoRow label={t('deployTab.panel.createdAt')} value={formatDate((observed || pending)?.createdAt)} />
|
||||
{pending && (
|
||||
<InfoRow label={t('deployTab.panel.targetRelease')} value={`${releaseLabel(pending)} / ${releaseCommit(pending)}`} mono />
|
||||
)}
|
||||
{failedRelease && (
|
||||
<InfoRow label={t('deployTab.panel.failedRelease')} value={`${failedRelease.id} / ${failedRelease.gateCommitId}`} mono />
|
||||
{row.instance?.lastError?.releaseId && (
|
||||
<InfoRow label={t('deployTab.panel.failedRelease')} value={row.instance.lastError.releaseId} mono />
|
||||
)}
|
||||
</InfoBlock>
|
||||
|
||||
<InfoBlock title={t('deployTab.panel.endpoints')}>
|
||||
<InfoRow label={t('deployTab.panel.run')} value={`/${env.namespace}/run`} mono />
|
||||
<InfoRow label={t('deployTab.panel.health')} value={`/${env.namespace}/readyz`} mono />
|
||||
<InfoRow label={t('deployTab.panel.run')} value={row.observedRuntime?.endpoints?.run ?? '—'} mono />
|
||||
<InfoRow label={t('deployTab.panel.health')} value={row.observedRuntime?.endpoints?.health ?? '—'} mono />
|
||||
</InfoBlock>
|
||||
|
||||
{modelCreds.length > 0 && (
|
||||
{credentials.length > 0 && (
|
||||
<InfoBlock title={t('deployTab.panel.modelCreds')}>
|
||||
{modelCreds.map(c => (
|
||||
{credentials.map(c => (
|
||||
<InfoRow
|
||||
key={`model-${c.provider}`}
|
||||
label={c.provider}
|
||||
value={credentialMap.get(c.credentialId ?? '')?.name ?? '—'}
|
||||
key={`${c.slot}-${c.displayName}-${c.maskedValue}`}
|
||||
label={c.slot ?? '—'}
|
||||
value={c.displayName || c.maskedValue || '—'}
|
||||
mono
|
||||
/>
|
||||
))}
|
||||
</InfoBlock>
|
||||
)}
|
||||
|
||||
{pluginCreds.length > 0 && (
|
||||
<InfoBlock title={t('deployTab.panel.pluginCreds')}>
|
||||
{pluginCreds.map(c => (
|
||||
<InfoRow
|
||||
key={`plugin-${c.provider}`}
|
||||
label={c.provider}
|
||||
value={credentialMap.get(c.credentialId ?? '')?.name ?? '—'}
|
||||
mono
|
||||
/>
|
||||
))}
|
||||
</InfoBlock>
|
||||
)}
|
||||
|
||||
{deployment.envVariables.length > 0 && (
|
||||
{envVars.length > 0 && (
|
||||
<InfoBlock title={t('deployTab.panel.envVars')}>
|
||||
{deployment.envVariables.map(v => (
|
||||
{envVars.map(v => (
|
||||
<InfoRow
|
||||
key={v.key}
|
||||
label={v.key}
|
||||
value={v.type === 'secret' ? '••••••' : v.value}
|
||||
key={`${v.slot}-${v.displayName}`}
|
||||
label={v.slot ?? '—'}
|
||||
value={v.maskedValue || v.displayName || '—'}
|
||||
mono
|
||||
suffix={` (${v.type})`}
|
||||
/>
|
||||
))}
|
||||
</InfoBlock>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deployment.status === 'deploy_failed' && deployment.errorMessage && (
|
||||
{row.instance?.lastError?.message && (
|
||||
<div className="mt-4 rounded-lg border border-util-colors-red-red-200 bg-util-colors-red-red-50 px-3 py-2 system-xs-regular text-util-colors-red-red-700">
|
||||
{deployment.errorMessage}
|
||||
{row.instance.lastError.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -152,22 +148,23 @@ const DeploymentPanel: FC<DeploymentPanelProps> = ({ deployment, env, release, t
|
||||
}
|
||||
|
||||
type DeploymentStatusSummaryProps = {
|
||||
deployment: Deployment
|
||||
row: EnvironmentDeploymentRow
|
||||
}
|
||||
|
||||
const DeploymentStatusSummary: FC<DeploymentStatusSummaryProps> = ({ deployment }) => {
|
||||
const DeploymentStatusSummary: FC<DeploymentStatusSummaryProps> = ({ row }) => {
|
||||
const { t } = useTranslation('deployments')
|
||||
const status = deploymentStatus(row)
|
||||
|
||||
if (deployment.status === 'deploying') {
|
||||
if (status === 'deploying') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-blue-blue-700">
|
||||
<span className="i-ri-loader-4-line h-3.5 w-3.5 animate-spin" />
|
||||
{t('deployTab.status.deployingRelease', { release: deployment.targetReleaseId ?? deployment.activeReleaseId })}
|
||||
{t('deployTab.status.deployingRelease', { release: releaseLabel(targetRelease(row) || activeRelease(row)) })}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (deployment.status === 'deploy_failed') {
|
||||
if (status === 'deploy_failed') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-warning-warning-700">
|
||||
<span className="i-ri-alert-line h-3.5 w-3.5" />
|
||||
@ -184,106 +181,26 @@ const DeploymentStatusSummary: FC<DeploymentStatusSummaryProps> = ({ deployment
|
||||
)
|
||||
}
|
||||
|
||||
type RowPrimaryActionProps = {
|
||||
deployment: Deployment
|
||||
onPromote: () => void
|
||||
onViewProgress: () => void
|
||||
onViewLogs: () => void
|
||||
}
|
||||
|
||||
const RowPrimaryAction: FC<RowPrimaryActionProps> = ({ deployment, onPromote, onViewProgress, onViewLogs }) => {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
if (deployment.status === 'deploying') {
|
||||
return (
|
||||
<Button size="small" variant="secondary" onClick={onViewProgress}>
|
||||
{t('deployTab.viewProgress')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
if (deployment.status === 'deploy_failed') {
|
||||
return (
|
||||
<Button size="small" variant="secondary" onClick={onViewLogs}>
|
||||
{t('deployTab.viewLogs')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button size="small" variant="secondary" onClick={onPromote}>
|
||||
{t('deployTab.deployOtherVersion')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
type DeploymentMenuProps = {
|
||||
deployment: Deployment
|
||||
onUndeploy: () => void
|
||||
}
|
||||
|
||||
const DeploymentMenu: FC<DeploymentMenuProps> = ({ deployment, onUndeploy }) => {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const itemLabel = deployment.status === 'deploying'
|
||||
? t('deployTab.cancelDeployment')
|
||||
: t('deployTab.undeploy')
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t('deployTab.moreActions')}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
>
|
||||
<span className="i-ri-more-line h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
{menuOpen && (
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[200px]">
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-3"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
onUndeploy()
|
||||
}}
|
||||
>
|
||||
<span className="system-sm-regular text-text-destructive">
|
||||
{itemLabel}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
type DeployTabProps = {
|
||||
instanceId: string
|
||||
}
|
||||
|
||||
const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
|
||||
const DeployTab: FC<DeployTabProps> = ({ 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<string | null>(() => 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<string | null>(() => 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 (
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
@ -293,7 +210,7 @@ const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
|
||||
{' '}
|
||||
<span className="system-sm-regular text-text-tertiary">
|
||||
(
|
||||
{instanceDeployments.length}
|
||||
{rows.length}
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
@ -315,7 +232,7 @@ const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
|
||||
className="gap-2 px-3"
|
||||
onClick={() => {
|
||||
setDeployMenuOpen(false)
|
||||
openDeployDrawer({ instanceId })
|
||||
openDeployDrawer({ appId })
|
||||
}}
|
||||
>
|
||||
<span className="system-sm-regular text-text-secondary">{t('deployTab.deployToNewEnv')}</span>
|
||||
@ -329,11 +246,11 @@ const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
|
||||
className="gap-2 px-3"
|
||||
onClick={() => {
|
||||
setDeployMenuOpen(false)
|
||||
openDeployDrawer({ instanceId, environmentId: env.id })
|
||||
openDeployDrawer({ appId, environmentId: env.id })
|
||||
}}
|
||||
>
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t('deployTab.deployToEnv', { name: env.name })}
|
||||
{t('deployTab.deployToEnv', { name: environmentName(env) })}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
@ -344,7 +261,7 @@ const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{instanceDeployments.length === 0
|
||||
{rows.length === 0
|
||||
? (
|
||||
<div className="rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-12 text-center system-sm-regular text-text-tertiary">
|
||||
{t('deployTab.empty')}
|
||||
@ -362,26 +279,34 @@ const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
|
||||
<div>{t('deployTab.col.status')}</div>
|
||||
<div />
|
||||
</div>
|
||||
{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 = (
|
||||
<div className="flex shrink-0 items-center gap-1" onClick={e => e.stopPropagation()}>
|
||||
<RowPrimaryAction
|
||||
deployment={deployment}
|
||||
onPromote={() => openDeployDrawer({ instanceId, environmentId: deployment.environmentId })}
|
||||
onViewProgress={() => setExpanded(deployment.id)}
|
||||
onViewLogs={() => setExpanded(deployment.id)}
|
||||
/>
|
||||
<DeploymentMenu
|
||||
deployment={deployment}
|
||||
onUndeploy={() => undeployDeployment(deployment.id)}
|
||||
/>
|
||||
<Button size="small" variant="secondary" onClick={() => openDeployDrawer({ appId, environmentId: envId })}>
|
||||
{status === 'ready' ? t('deployTab.deployOtherVersion') : t('deployTab.viewProgress')}
|
||||
</Button>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t('deployTab.moreActions')}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
>
|
||||
<span className="i-ri-more-line h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[200px]">
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-3"
|
||||
onClick={() => undeployDeployment(appId, envId, deploymentId(row), status === 'deploying')}
|
||||
>
|
||||
<span className="system-sm-regular text-text-destructive">
|
||||
{status === 'deploying' ? t('deployTab.cancelDeployment') : t('deployTab.undeploy')}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
const chevron = (
|
||||
@ -393,10 +318,10 @@ const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div key={deployment.id} className="border-b border-divider-subtle last:border-b-0">
|
||||
<div key={envId} className="border-b border-divider-subtle last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(deployment.id)}
|
||||
onClick={() => toggle(envId)}
|
||||
className={cn(
|
||||
'flex w-full flex-col gap-2 px-4 py-3 text-left hover:bg-state-base-hover',
|
||||
'lg:grid lg:items-center lg:gap-4',
|
||||
@ -405,11 +330,11 @@ const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 lg:block">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate system-sm-semibold text-text-primary">{env.name}</span>
|
||||
<span className="truncate system-sm-semibold text-text-primary">{environmentName(row.environment)}</span>
|
||||
<div className="flex items-center gap-1.5 system-xs-regular text-text-tertiary">
|
||||
<span className="uppercase">{env.backend}</span>
|
||||
<span className="uppercase">{environmentBackend(row.environment)}</span>
|
||||
<span>·</span>
|
||||
<span>{t(env.mode === 'isolated' ? 'mode.isolated' : 'mode.shared')}</span>
|
||||
<span>{t(environmentMode(row.environment) === 'isolated' ? 'mode.isolated' : 'mode.shared')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 lg:hidden">
|
||||
@ -419,13 +344,11 @@ const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 lg:contents">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono system-sm-medium text-text-primary">{deployment.activeReleaseId}</span>
|
||||
{release && (
|
||||
<span className="font-mono system-xs-regular text-text-tertiary">{release.gateCommitId}</span>
|
||||
)}
|
||||
<span className="font-mono system-sm-medium text-text-primary">{releaseLabel(release)}</span>
|
||||
<span className="font-mono system-xs-regular text-text-tertiary">{releaseCommit(release)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<DeploymentStatusSummary deployment={deployment} />
|
||||
<DeploymentStatusSummary row={row} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden items-center justify-end gap-1 lg:flex">
|
||||
@ -433,15 +356,7 @@ const DeployTab: FC<DeployTabProps> = ({ instanceId }) => {
|
||||
{chevron}
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<DeploymentPanel
|
||||
deployment={deployment}
|
||||
env={env}
|
||||
release={release}
|
||||
targetRelease={targetRelease}
|
||||
failedRelease={failedRelease}
|
||||
/>
|
||||
)}
|
||||
{isExpanded && <DeploymentPanel row={row} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -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<InstanceDetailProps> = ({ 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<InstanceDetailProps> = ({ instanceId, children }) => {
|
||||
)
|
||||
}
|
||||
|
||||
if (!instance) {
|
||||
if (!app) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 bg-background-body">
|
||||
<div className="title-xl-semi-bold text-text-primary">{t('detail.notFound')}</div>
|
||||
@ -254,8 +254,8 @@ const InstanceDetail: FC<InstanceDetailProps> = ({ 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<InstanceDetailProps> = ({ instanceId, children }) => {
|
||||
<div className="relative flex h-full overflow-hidden rounded-t-2xl shadow-[0_0_5px_rgba(0,0,0,0.05),0_0_2px_-1px_rgba(0,0,0,0.03)]">
|
||||
<DeploymentSidebar
|
||||
instanceId={instanceId}
|
||||
instanceName={instance.name}
|
||||
instanceDescription={instance.description}
|
||||
instanceName={app.name}
|
||||
instanceDescription={app.description}
|
||||
appModeLabel={appModeLabel}
|
||||
app={app}
|
||||
/>
|
||||
@ -279,7 +279,7 @@ const InstanceDetail: FC<InstanceDetailProps> = ({ instanceId, children }) => {
|
||||
<div className="system-xs-regular text-text-tertiary">{t(`tabs.${activeTab}.description`)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 system-xs-regular text-text-tertiary">
|
||||
<span>{t('detail.envCount', { count: instanceDeployments.length })}</span>
|
||||
<span>{t('detail.envCount', { count: appDeployments.length })}</span>
|
||||
{deployingCount > 0 && (
|
||||
<>
|
||||
<span>·</span>
|
||||
|
||||
@ -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<InfoRowProps> = ({ label, value, mono }) => (
|
||||
</div>
|
||||
)
|
||||
|
||||
type SwitchSourceAppDialogProps = {
|
||||
open: boolean
|
||||
instanceId: string
|
||||
currentAppId: string
|
||||
apps: AppInfo[]
|
||||
isLoading: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const SwitchSourceAppDialog: FC<SwitchSourceAppDialogProps> = ({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={next => !next && onClose()}>
|
||||
<DialogContent className="w-[520px] max-w-[90vw]">
|
||||
<DialogCloseButton />
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<DialogTitle className="title-xl-semi-bold text-text-primary">
|
||||
{t('overview.switchSourceApp')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{t('overview.switchSourceAppDescription')}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="system-xs-medium-uppercase text-text-tertiary">
|
||||
{t('createModal.sourceApp')}
|
||||
</label>
|
||||
<AppPicker
|
||||
apps={apps}
|
||||
isLoading={isLoading}
|
||||
value={pickerValue}
|
||||
onChange={setSelectedAppId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-components-panel-border bg-background-default-subtle px-3 py-2 system-xs-regular text-text-tertiary">
|
||||
{t('overview.switchSourceAppHint')}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('createModal.cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={!canSwitch} onClick={handleSwitch}>
|
||||
{t('overview.switchSourceApp')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
type AccessOverviewRowProps = {
|
||||
label: string
|
||||
enabled: boolean
|
||||
@ -157,221 +78,108 @@ const AccessOverviewRow: FC<AccessOverviewRowProps> = ({ label, enabled, hint })
|
||||
|
||||
const OverviewTab: FC<OverviewTabProps> = ({ 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 (
|
||||
<>
|
||||
<div className="flex flex-col gap-5 p-6">
|
||||
{sourceAppMissing && (
|
||||
<div className="flex gap-3 rounded-xl border border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 p-4">
|
||||
<RiErrorWarningLine className="mt-0.5 h-4 w-4 shrink-0 text-util-colors-warning-warning-700" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="system-sm-semibold text-util-colors-warning-warning-700">
|
||||
{t('overview.sourceAppDeletedTitle')}
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-secondary">
|
||||
{t('overview.sourceAppDeletedDescription')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSwitchSourceOpen(true)}
|
||||
className="mt-1 flex items-center gap-1 self-start system-xs-medium text-text-accent hover:underline"
|
||||
>
|
||||
<RiExchangeLine className="h-3 w-3" />
|
||||
{t('overview.switchSourceApp')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5 p-6">
|
||||
<Section title={t('overview.basicInfo')}>
|
||||
<div className="flex flex-col divide-y divide-divider-subtle">
|
||||
<InfoRow label={t('overview.name')} value={app.name} />
|
||||
<InfoRow label={t('overview.description')} value={app.description ?? t('overview.emptyValue')} />
|
||||
<InfoRow label={t('overview.sourceApp')} value={app.name} />
|
||||
<InfoRow label={t('overview.appMode')} value={appModeLabel} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title={t('overview.deploymentStatus')}
|
||||
action={(
|
||||
<Button size="small" variant="secondary" onClick={() => onSwitchTab?.('deploy')}>
|
||||
{t('overview.viewDeployments')}
|
||||
<span className="i-ri-arrow-right-up-line h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Section title={t('overview.basicInfo')}>
|
||||
<div className="flex flex-col divide-y divide-divider-subtle">
|
||||
<InfoRow label={t('overview.name')} value={instance.name} />
|
||||
<InfoRow label={t('overview.description')} value={instance.description ?? t('overview.emptyValue')} />
|
||||
<InfoRow
|
||||
label={t('overview.sourceApp')}
|
||||
value={(
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className={cn('min-w-0 truncate', sourceAppMissing && 'text-util-colors-warning-warning-700')}>
|
||||
{app?.name ?? t('overview.sourceAppDeletedValue')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSwitchSourceOpen(true)}
|
||||
className="inline-flex shrink-0 items-center gap-1 system-xs-medium text-text-accent hover:underline"
|
||||
>
|
||||
<RiExchangeLine className="h-3 w-3" />
|
||||
{t('overview.switchSourceApp')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<InfoRow label={t('overview.appMode')} value={appModeLabel} />
|
||||
<InfoRow label={t('overview.instanceId')} value={instance.id} mono />
|
||||
<InfoRow label={t('overview.created')} value={instance.createdAt} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title={t('overview.deploymentStatus')}
|
||||
action={onSwitchTab && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSwitchTab('deploy')}
|
||||
className="flex items-center gap-1 system-xs-medium text-text-accent hover:underline"
|
||||
>
|
||||
{t('overview.viewDeployments')}
|
||||
<RiArrowRightUpLine className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{instanceDeployments.length === 0
|
||||
? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-8 text-center">
|
||||
<div className="system-sm-regular text-text-tertiary">
|
||||
{t('overview.notDeployedYet')}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => openDeployDrawer({ instanceId })}
|
||||
>
|
||||
<RiRocketLine className="h-3.5 w-3.5" />
|
||||
{t('overview.deploy')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex flex-col divide-y divide-divider-subtle">
|
||||
{instanceDeployments.map((deployment) => {
|
||||
const env = envMap.get(deployment.environmentId)
|
||||
if (!env)
|
||||
return null
|
||||
return (
|
||||
<div key={deployment.id} className="flex items-center justify-between py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="system-sm-semibold text-text-primary">{env.name}</span>
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{t(env.mode === 'isolated' ? 'mode.isolated' : 'mode.shared')}
|
||||
{' · '}
|
||||
{env.backend.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono system-sm-regular text-text-secondary">
|
||||
{deployment.activeReleaseId}
|
||||
</span>
|
||||
<StatusBadge status={deployment.status} />
|
||||
</div>
|
||||
>
|
||||
{deployments.length === 0
|
||||
? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-8 text-center">
|
||||
<span className="i-ri-rocket-line h-5 w-5 text-text-quaternary" />
|
||||
<div className="system-sm-regular text-text-tertiary">{t('overview.notDeployedYet')}</div>
|
||||
<Button size="small" variant="primary" onClick={() => openDeployDrawer({ appId: app.id })}>
|
||||
{t('overview.deploy')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex flex-col divide-y divide-divider-subtle">
|
||||
{deployments.map((row) => {
|
||||
const status = deploymentStatus(row)
|
||||
return (
|
||||
<div key={row.environment?.id} className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="system-sm-medium text-text-primary">{environmentName(row.environment)}</span>
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{releaseLabel(row.observedRuntime?.release || row.pendingDeployment?.release)}
|
||||
{' · '}
|
||||
{formatDate(row.instance?.lastDeployedAt || row.instance?.lastReadyAt)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title={t('overview.accessStatus')}
|
||||
action={onSwitchTab && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSwitchTab('access')}
|
||||
className="flex items-center gap-1 system-xs-medium text-text-accent hover:underline"
|
||||
>
|
||||
{t('overview.configureAccess')}
|
||||
<RiArrowRightUpLine className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-divider-subtle bg-background-default-subtle p-3">
|
||||
<div className="system-xs-semibold text-text-primary">{t('overview.endUserAccess')}</div>
|
||||
<div className="flex flex-col divide-y divide-divider-subtle">
|
||||
{endUserAccessEntries.map(entry => (
|
||||
<AccessOverviewRow key={entry.label} {...entry} />
|
||||
))}
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-divider-subtle bg-background-default-subtle p-3">
|
||||
<div className="system-xs-semibold text-text-primary">{t('overview.developerApi')}</div>
|
||||
<div className="flex flex-col divide-y divide-divider-subtle">
|
||||
{developerAccessEntries.map(entry => (
|
||||
<AccessOverviewRow key={entry.label} {...entry} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<SwitchSourceAppDialog
|
||||
open={switchSourceOpen}
|
||||
instanceId={instance.id}
|
||||
currentAppId={instance.appId}
|
||||
apps={apps}
|
||||
isLoading={isLoadingApps}
|
||||
onClose={() => setSwitchSourceOpen(false)}
|
||||
/>
|
||||
</>
|
||||
<Section
|
||||
title={t('overview.accessStatus')}
|
||||
action={(
|
||||
<Button size="small" variant="secondary" onClick={() => onSwitchTab?.('access')}>
|
||||
{t('overview.configureAccess')}
|
||||
<span className="i-ri-arrow-right-up-line h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col divide-y divide-divider-subtle">
|
||||
<AccessOverviewRow
|
||||
label={t('overview.webapp')}
|
||||
enabled={appData?.accessConfig.webapp?.enabled ?? false}
|
||||
hint={webappAccessUrl || t('overview.notConfigured')}
|
||||
/>
|
||||
<AccessOverviewRow
|
||||
label={t('overview.cli')}
|
||||
enabled={appData?.accessConfig.cli?.enabled ?? false}
|
||||
hint={cliUrl ?? t('overview.notConfigured')}
|
||||
/>
|
||||
<AccessOverviewRow
|
||||
label={t('overview.api')}
|
||||
enabled={appData?.accessConfig.developerApi?.enabled ?? false}
|
||||
hint={appData?.accessConfig.developerApi?.enabled
|
||||
? t('overview.apiKeysCount', { count: apiKeysCount })
|
||||
: t('overview.notConfigured')}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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<SettingsFormProps> = ({ instance, hasDeployments }) => {
|
||||
const SettingsForm: FC<SettingsFormProps> = ({ app, hasDeployments }) => {
|
||||
const { t } = useTranslation('deployments')
|
||||
const router = useRouter()
|
||||
const updateInstance = useDeploymentsStore(state => state.updateInstance)
|
||||
const deleteInstance = useDeploymentsStore(state => state.deleteInstance)
|
||||
|
||||
const [name, setName] = useState(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<SettingsFormProps> = ({ 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<SettingsFormProps> = ({ instance, hasDeployments }) => {
|
||||
toast.error(t('settings.undeployFirst'))
|
||||
return
|
||||
}
|
||||
deleteInstance(instance.id)
|
||||
deleteInstance(app.id)
|
||||
router.push('/deployments')
|
||||
}
|
||||
|
||||
@ -116,18 +118,18 @@ const SettingsForm: FC<SettingsFormProps> = ({ instance, hasDeployments }) => {
|
||||
}
|
||||
|
||||
const SettingsTab: FC<SettingsTabProps> = ({ 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 <SettingsForm key={formKey} instance={instance} hasDeployments={hasDeployments} />
|
||||
return <SettingsForm key={formKey} app={app} hasDeployments={hasDeployments} />
|
||||
}
|
||||
|
||||
export default SettingsTab
|
||||
|
||||
@ -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<ReleaseDeploymentState, string> = {
|
||||
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<DeployReleaseMenuProps> = ({ 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<DeployReleaseMenuProps> = ({ 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 (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
||||
@ -58,36 +98,40 @@ const DeployReleaseMenu: FC<DeployReleaseMenuProps> = ({ releaseId, instanceId }
|
||||
{open && (
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[220px]">
|
||||
{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 (
|
||||
<DropdownMenuItem
|
||||
key={env.id}
|
||||
key={envId}
|
||||
className="gap-2 px-3"
|
||||
disabled={isCurrent || isEnvironmentDeploying}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
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 })
|
||||
}}
|
||||
>
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{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) })}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
@ -98,46 +142,6 @@ const DeployReleaseMenu: FC<DeployReleaseMenuProps> = ({ releaseId, instanceId }
|
||||
)
|
||||
}
|
||||
|
||||
type ReleaseMoreMenuProps = {
|
||||
previewVisible: boolean
|
||||
onTogglePreview: () => void
|
||||
}
|
||||
|
||||
const ReleaseMoreMenu: FC<ReleaseMoreMenuProps> = ({ previewVisible, onTogglePreview }) => {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t('versions.moreActions')}
|
||||
className={cn(
|
||||
open ? 'bg-state-base-hover text-text-secondary' : 'text-text-tertiary',
|
||||
'flex h-7 w-7 items-center justify-center rounded-md hover:bg-state-base-hover hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
<span className="i-ri-more-line h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
{open && (
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[180px]">
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-3"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onTogglePreview()
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-file-code-line h-4 w-4 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{previewVisible ? t('versions.hideYaml') : t('versions.viewYaml')}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
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<VersionsTabProps> = ({ instanceId }) => {
|
||||
const VersionsTab: FC<VersionsTabProps> = ({ 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<string>()
|
||||
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<string | null>(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<VersionsTabProps> = ({ instanceId }) => {
|
||||
{' '}
|
||||
<span className="system-sm-regular text-text-tertiary">
|
||||
(
|
||||
{appReleases.length}
|
||||
{releaseRows.length}
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{appReleases.length === 0
|
||||
{releaseRows.length === 0
|
||||
? (
|
||||
<div className="rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-12 text-center system-sm-regular text-text-tertiary">
|
||||
{t('versions.empty')}
|
||||
@ -275,9 +263,9 @@ const VersionsTab: FC<VersionsTabProps> = ({ instanceId }) => {
|
||||
<div className="text-right">{t('versions.col.action')}</div>
|
||||
</div>
|
||||
|
||||
{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 (
|
||||
<div key={release.id} className="border-b border-divider-subtle last:border-b-0">
|
||||
<div className="flex flex-col gap-3 px-4 py-3 lg:hidden">
|
||||
@ -290,26 +278,22 @@ const VersionsTab: FC<VersionsTabProps> = ({ instanceId }) => {
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="mt-1 inline-flex max-w-full cursor-default truncate font-mono system-sm-medium text-text-primary">
|
||||
{release.id}
|
||||
{releaseLabel(release)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('versions.commitTooltip', { commit: release.gateCommitId })}
|
||||
{t('versions.commitTooltip', { commit: releaseCommit(release) })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 system-xs-regular text-text-tertiary">
|
||||
<span>{release.createdAt}</span>
|
||||
<span>{formatDate(release.createdAt)}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{release.operator}</span>
|
||||
<span>{row.createdBy?.displayName ?? '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 justify-end gap-1">
|
||||
<DeployReleaseMenu releaseId={release.id} instanceId={instanceId} />
|
||||
<ReleaseMoreMenu
|
||||
previewVisible={isPreview}
|
||||
onTogglePreview={() => setPreviewId(prev => (prev === release.id ? null : release.id))}
|
||||
/>
|
||||
<DeployReleaseMenu releaseId={release.id!} appId={appId} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@ -338,17 +322,17 @@ const VersionsTab: FC<VersionsTabProps> = ({ instanceId }) => {
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="inline-flex cursor-default font-mono system-sm-medium text-text-primary">
|
||||
{release.id}
|
||||
{releaseLabel(release)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('versions.commitTooltip', { commit: release.gateCommitId })}
|
||||
{t('versions.commitTooltip', { commit: releaseCommit(release) })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="system-sm-regular text-text-secondary">{release.createdAt}</div>
|
||||
<div className="system-sm-regular text-text-secondary">{release.operator}</div>
|
||||
<div className="system-sm-regular text-text-secondary">{formatDate(release.createdAt)}</div>
|
||||
<div className="system-sm-regular text-text-secondary">{row.createdBy?.displayName ?? '—'}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{releaseDeployments.length === 0
|
||||
? <span className="system-sm-regular text-text-quaternary">—</span>
|
||||
@ -360,20 +344,9 @@ const VersionsTab: FC<VersionsTabProps> = ({ instanceId }) => {
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end gap-1">
|
||||
<DeployReleaseMenu releaseId={release.id} instanceId={instanceId} />
|
||||
<ReleaseMoreMenu
|
||||
previewVisible={isPreview}
|
||||
onTogglePreview={() => setPreviewId(prev => (prev === release.id ? null : release.id))}
|
||||
/>
|
||||
<DeployReleaseMenu releaseId={release.id!} appId={appId} />
|
||||
</div>
|
||||
</div>
|
||||
{isPreview && (
|
||||
<div className="border-t border-divider-subtle bg-background-default-subtle">
|
||||
<pre className="overflow-auto px-4 py-3 font-mono text-[12.5px] leading-5 text-text-secondary">
|
||||
{release.yaml}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -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: [],
|
||||
},
|
||||
]
|
||||
@ -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 = () => {
|
||||
<AlertDialogContent className="w-[520px]">
|
||||
<div className="flex flex-col gap-3 px-6 pt-6 pb-2">
|
||||
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t('rollback.title', { release: targetRelease?.id ?? '-' })}
|
||||
{t('rollback.title', { release: releaseLabel(targetRelease) })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="system-md-regular text-text-tertiary">
|
||||
{t('rollback.description')}
|
||||
</AlertDialogDescription>
|
||||
|
||||
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-components-panel-border bg-components-panel-bg-blur p-3">
|
||||
<InfoRow label={t('rollback.instance')} value={instance?.name ?? '-'} />
|
||||
<InfoRow label={t('rollback.instance')} value={app?.name ?? '-'} />
|
||||
<InfoRow label={t('rollback.sourceApp')} value={app?.name ?? '-'} />
|
||||
<InfoRow label={t('rollback.environment')} value={environment?.name ?? '-'} />
|
||||
<InfoRow label={t('rollback.environment')} value={environmentName(environment)} />
|
||||
<InfoRow
|
||||
label={t('rollback.currentRelease')}
|
||||
value={currentRelease ? `${currentRelease.id} / ${currentRelease.gateCommitId}` : '-'}
|
||||
value={currentRelease ? `${releaseLabel(currentRelease)} / ${releaseCommit(currentRelease)}` : '-'}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t('rollback.rollbackTo')}
|
||||
value={targetRelease ? `${targetRelease.id} / ${targetRelease.gateCommitId}` : '-'}
|
||||
value={targetRelease ? `${releaseLabel(targetRelease)} / ${releaseCommit(targetRelease)}` : '-'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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<APIToken, 'id' | 'environmentId' | 'maskedPrefix' | 'name'> & {
|
||||
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<string, DeploymentAppData>
|
||||
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<void>
|
||||
|
||||
createInstance: (params: CreateInstanceParams) => string
|
||||
updateInstance: (instanceId: string, patch: Partial<Pick<Instance, 'name' | 'description'>>) => void
|
||||
switchSourceApp: (instanceId: string, appId: string) => void
|
||||
deleteInstance: (instanceId: string) => void
|
||||
updateInstance: (appId: string, patch: Partial<Pick<AppInfo, 'name' | 'description'>>) => 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<void>
|
||||
retryDeploy: (appId: string, environmentId: string, targetReleaseId: string) => Promise<void>
|
||||
rollbackDeployment: (appId: string, environmentId: string, targetReleaseId: string) => Promise<void>
|
||||
undeployDeployment: (appId: string, environmentId: string, deploymentId?: string, isDeploying?: boolean) => Promise<void>
|
||||
|
||||
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<void>
|
||||
revokeApiKey: (appId: string, environmentId: string, apiKeyId: string) => Promise<void>
|
||||
clearCreatedApiToken: () => void
|
||||
toggleAccessChannel: (appId: string, channel: string, enabled: boolean, expectedVersion: number) => Promise<void>
|
||||
setEnvironmentAccessPolicy: (
|
||||
appId: string,
|
||||
environmentId: string,
|
||||
members: { memberIds: string[], groupIds: string[] },
|
||||
) => void
|
||||
}
|
||||
|
||||
function updateDeployment(deployments: Deployment[], deploymentId: string, patch: Partial<Deployment>): Deployment[] {
|
||||
return deployments.map(item => item.id === deploymentId ? { ...item, ...patch } : item)
|
||||
channel: string,
|
||||
enabled: boolean,
|
||||
accessMode: string,
|
||||
expectedVersion: number,
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
export const useDeploymentsStore = create<DeploymentsState>((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<DeploymentsState>((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<string, string> = {}
|
||||
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)
|
||||
},
|
||||
}))
|
||||
|
||||
@ -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<AccessMethod, boolean>
|
||||
webappUrl?: string
|
||||
mcpUrl?: string
|
||||
envPermissions: EnvAccessPermission[]
|
||||
}
|
||||
|
||||
42
web/app/components/deployments/use-deployment-data.ts
Normal file
42
web/app/components/deployments/use-deployment-data.ts
Normal file
@ -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),
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<NavItem[]>(() => {
|
||||
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)
|
||||
|
||||
703
web/contract/console/deployments.ts
Normal file
703
web/contract/console/deployments.ts
Normal file
@ -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<string, never>
|
||||
|
||||
export const deploymentOverviewContract = base
|
||||
.route({
|
||||
path: '/enterprise/apps/{appId}/deploy/overview',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(type<{ params: { appId: string } }>())
|
||||
.output(type<GetDeploymentOverviewReply>())
|
||||
|
||||
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<ListEnvironmentDeploymentsReply>())
|
||||
|
||||
export const deploymentCandidatesContract = base
|
||||
.route({
|
||||
path: '/enterprise/apps/{appId}/deploy/deployment-candidates',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(type<{ params: { appId: string } }>())
|
||||
.output(type<ListDeploymentCandidatesReply>())
|
||||
|
||||
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<GetDeploymentPlanReply>())
|
||||
|
||||
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<ListReleaseHistoryReply>())
|
||||
|
||||
export const accessConfigContract = base
|
||||
.route({
|
||||
path: '/enterprise/apps/{appId}/deploy/access-config',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(type<{ params: { appId: string } }>())
|
||||
.output(type<GetAccessConfigReply>())
|
||||
|
||||
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<GetEnvironmentAccessPolicyReply>())
|
||||
|
||||
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<UpdateEnvironmentAccessPolicyReply>())
|
||||
|
||||
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<SearchAccessSubjectsReply>())
|
||||
|
||||
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<PatchAccessChannelReply>())
|
||||
|
||||
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<CreateReleaseReply>())
|
||||
|
||||
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<CreateDeploymentReply>())
|
||||
|
||||
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<CancelDeploymentReply>())
|
||||
|
||||
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<UndeployEnvironmentReply>())
|
||||
|
||||
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<RollbackEnvironmentReply>())
|
||||
|
||||
export const environmentAPITokensContract = base
|
||||
.route({
|
||||
path: '/enterprise/apps/{appId}/environments/{environmentId}/api-keys',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(type<{
|
||||
params: {
|
||||
appId: string
|
||||
environmentId: string
|
||||
}
|
||||
}>())
|
||||
.output(type<ListEnvironmentAPITokensReply>())
|
||||
|
||||
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<CreateEnvironmentAPITokenReply>())
|
||||
|
||||
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<DeleteEnvironmentAPITokenReply>())
|
||||
@ -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,
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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 入口遵循上方访问权限。",
|
||||
|
||||
@ -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('; ')
|
||||
}
|
||||
|
||||
@ -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<typeof fetch>().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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
214
web/service/deployments.ts
Normal file
214
web/service/deployments.ts
Normal file
@ -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<DeploymentAppData> => {
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user