mirror of
https://github.com/langgenius/dify.git
synced 2026-05-30 05:37:48 +08:00
update
This commit is contained in:
@ -0,0 +1,8 @@
|
||||
import { AccessTab } from '@/features/deployments/detail/access-tab'
|
||||
|
||||
export default async function InstanceDetailAccessPage({ params }: {
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
return <AccessTab appInstanceId={appInstanceId} />
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
import { DeveloperApiTab } from '@/features/deployments/detail/developer-api-tab'
|
||||
|
||||
export default async function InstanceDetailApiPage({ params }: {
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
return <DeveloperApiTab appInstanceId={appInstanceId} />
|
||||
}
|
||||
15
web/features/deployments/detail/access-tab.tsx
Normal file
15
web/features/deployments/detail/access-tab.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { AccessChannelsSection } from './settings-tab/access/channels-section'
|
||||
import { AccessPermissionsSection } from './settings-tab/access/permissions-section'
|
||||
|
||||
export function AccessTab({ appInstanceId }: {
|
||||
appInstanceId: string
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1180px] min-w-0 flex-col gap-y-5 px-6 py-6 sm:py-8">
|
||||
<AccessPermissionsSection appInstanceId={appInstanceId} />
|
||||
<AccessChannelsSection appInstanceId={appInstanceId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -10,6 +10,7 @@ type SectionProps = {
|
||||
children: ReactNode
|
||||
layout?: 'block' | 'row'
|
||||
tone?: 'default' | 'destructive'
|
||||
showDivider?: boolean
|
||||
}
|
||||
|
||||
export function SectionState({ children }: {
|
||||
@ -39,6 +40,7 @@ export function Section({
|
||||
children,
|
||||
layout = 'block',
|
||||
tone = 'default',
|
||||
showDivider = true,
|
||||
}: SectionProps) {
|
||||
const titleClassName = cn(
|
||||
'system-sm-semibold',
|
||||
@ -55,7 +57,7 @@ export function Section({
|
||||
|
||||
if (layout === 'row') {
|
||||
return (
|
||||
<section className="border-b border-divider-subtle py-4 first:pt-0 last:border-b-0 last:pb-0">
|
||||
<section className={cn('py-4 first:pt-0 last:pb-0', showDivider && 'border-b border-divider-subtle last:border-b-0')}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:gap-x-6">
|
||||
<div className="flex min-w-0 shrink-0 flex-col sm:w-40 sm:pt-1">
|
||||
<div className={titleClassName}>
|
||||
@ -87,7 +89,7 @@ export function Section({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="border-b border-divider-subtle py-6 first:pt-0 last:border-b-0 last:pb-0">
|
||||
<section className={cn('py-6 first:pt-0 last:pb-0', showDivider && 'border-b border-divider-subtle last:border-b-0')}>
|
||||
<div className="mb-3 flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className={titleClassName}>
|
||||
|
||||
@ -53,6 +53,18 @@ function VersionsIcon({ className }: TailwindNavIconProps) {
|
||||
function VersionsSelectedIcon({ className }: TailwindNavIconProps) {
|
||||
return <span aria-hidden className={cn('i-ri-stack-fill', className)} />
|
||||
}
|
||||
function AccessIcon({ className }: TailwindNavIconProps) {
|
||||
return <span aria-hidden className={cn('i-ri-shield-user-line', className)} />
|
||||
}
|
||||
function AccessSelectedIcon({ className }: TailwindNavIconProps) {
|
||||
return <span aria-hidden className={cn('i-ri-shield-user-fill', className)} />
|
||||
}
|
||||
function ApiIcon({ className }: TailwindNavIconProps) {
|
||||
return <span aria-hidden className={cn('i-ri-code-s-slash-line', className)} />
|
||||
}
|
||||
function ApiSelectedIcon({ className }: TailwindNavIconProps) {
|
||||
return <span aria-hidden className={cn('i-ri-code-s-slash-fill', className)} />
|
||||
}
|
||||
function SettingsIcon({ className }: TailwindNavIconProps) {
|
||||
return <span aria-hidden className={cn('i-ri-settings-3-line', className)} />
|
||||
}
|
||||
@ -64,6 +76,8 @@ const TABS: TabDef[] = [
|
||||
{ key: 'overview', icon: OverviewIcon, selectedIcon: OverviewSelectedIcon },
|
||||
{ key: 'deploy', icon: DeployIcon, selectedIcon: DeploySelectedIcon },
|
||||
{ key: 'releases', icon: VersionsIcon, selectedIcon: VersionsSelectedIcon },
|
||||
{ key: 'access', icon: AccessIcon, selectedIcon: AccessSelectedIcon },
|
||||
{ key: 'api', icon: ApiIcon, selectedIcon: ApiSelectedIcon },
|
||||
{ key: 'settings', icon: SettingsIcon, selectedIcon: SettingsSelectedIcon },
|
||||
]
|
||||
|
||||
|
||||
13
web/features/deployments/detail/developer-api-tab.tsx
Normal file
13
web/features/deployments/detail/developer-api-tab.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { DeveloperApiSection } from './settings-tab/access/developer-api-section'
|
||||
|
||||
export function DeveloperApiTab({ appInstanceId }: {
|
||||
appInstanceId: string
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1180px] min-w-0 flex-col gap-y-5 px-6 py-6 sm:py-8">
|
||||
<DeveloperApiSection appInstanceId={appInstanceId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -7,6 +7,7 @@ import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useSelectedLayoutSegment } from '@/next/navigation'
|
||||
import { DeployDrawer } from '../components/deploy-drawer'
|
||||
import { DeploymentSidebar } from './deployment-sidebar'
|
||||
import { DeveloperApiHeaderActions } from './settings-tab/access/developer-api-section'
|
||||
import { isInstanceDetailTabKey } from './tabs'
|
||||
|
||||
export function InstanceDetail({ appInstanceId, children }: {
|
||||
@ -27,8 +28,17 @@ export function InstanceDetail({ appInstanceId, children }: {
|
||||
<div className="min-w-0 grow overflow-hidden bg-components-panel-bg">
|
||||
<div className="h-full min-w-0 overflow-y-auto">
|
||||
<div className="mx-auto flex w-full max-w-[1280px] flex-col gap-y-0.5 px-6 pt-3 pb-2 2xl:max-w-[1440px]">
|
||||
<div className="system-xl-semibold text-text-primary">{t(`tabs.${activeTab}.name`)}</div>
|
||||
<div className="system-sm-regular text-text-tertiary">{t(`tabs.${activeTab}.description`)}</div>
|
||||
<div className="flex min-w-0 items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="system-xl-semibold text-text-primary">{t(`tabs.${activeTab}.name`)}</div>
|
||||
<div className="system-sm-regular text-text-tertiary">{t(`tabs.${activeTab}.description`)}</div>
|
||||
</div>
|
||||
{activeTab === 'api' && (
|
||||
<div className="shrink-0 pt-1.5">
|
||||
<DeveloperApiHeaderActions appInstanceId={appInstanceId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@ -22,9 +22,6 @@ import { useRouter } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { isUndeployedDeploymentRow } from '../runtime-status'
|
||||
import { Section, SectionState } from './common'
|
||||
import { AccessChannelsSection } from './settings-tab/access/channels-section'
|
||||
import { DeveloperApiSection } from './settings-tab/access/developer-api-section'
|
||||
import { AccessPermissionsSection } from './settings-tab/access/permissions-section'
|
||||
|
||||
type AppInstanceWithId = AppInstanceBasicInfo & { id: string }
|
||||
|
||||
@ -404,9 +401,6 @@ export function SettingsTab({ appInstanceId }: {
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1080px] min-w-0 flex-col gap-y-4 px-6 py-6 sm:py-8">
|
||||
<AccessPermissionsSection appInstanceId={appInstanceId} />
|
||||
<AccessChannelsSection appInstanceId={appInstanceId} />
|
||||
<DeveloperApiSection appInstanceId={appInstanceId} />
|
||||
<SettingsFormSection appInstanceId={appInstanceId} />
|
||||
<DeleteInstanceControlSection appInstanceId={appInstanceId} />
|
||||
</div>
|
||||
|
||||
@ -38,26 +38,79 @@ function ApiKeyRow({ appInstanceId, apiKey }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className="flex min-w-35 flex-col">
|
||||
<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: environmentLabel })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 rounded-lg border border-components-input-border-active bg-components-input-bg-normal pr-1 pl-2">
|
||||
<div className="min-w-0 flex-1 truncate font-mono system-sm-medium text-text-secondary">
|
||||
{displayValue}
|
||||
<tr className="border-t border-divider-subtle">
|
||||
<td className="px-3 py-2.5 align-middle">
|
||||
<div className="max-w-54 truncate system-sm-medium text-text-primary">
|
||||
{apiKey.name || apiKey.id}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 align-middle">
|
||||
<span className="inline-flex h-5 max-w-36 items-center rounded-md bg-background-section-burn px-1.5 system-xs-medium text-text-tertiary">
|
||||
<span className="truncate">{environmentLabel}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 align-middle">
|
||||
<div className="flex h-8 min-w-0 items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-2">
|
||||
<div className="min-w-0 flex-1 truncate font-mono system-sm-medium text-text-secondary">
|
||||
{displayValue}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right align-middle">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRevoke}
|
||||
aria-label={t('access.revoke')}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive"
|
||||
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive"
|
||||
>
|
||||
<span className="i-ri-delete-bin-line size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiKeyTableHeader() {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<thead>
|
||||
<tr className="bg-background-default-subtle text-left system-xs-medium-uppercase text-text-tertiary">
|
||||
<th className="w-56 px-3 py-2 font-medium">
|
||||
{t('access.api.table.name')}
|
||||
</th>
|
||||
<th className="w-40 px-3 py-2 font-medium">
|
||||
{t('access.api.table.environment')}
|
||||
</th>
|
||||
<th className="px-3 py-2 font-medium">
|
||||
{t('access.api.table.key')}
|
||||
</th>
|
||||
<th className="w-18 px-3 py-2 text-right font-medium">
|
||||
{t('access.api.table.action')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiKeyTable({ appInstanceId, apiKeys }: {
|
||||
appInstanceId: string
|
||||
apiKeys: DeveloperApiKeyRow[]
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-divider-subtle">
|
||||
<table className="w-full min-w-175 table-fixed border-collapse">
|
||||
<ApiKeyTableHeader />
|
||||
<tbody className="bg-components-panel-bg">
|
||||
{apiKeys.map(apiKey => (
|
||||
<ApiKeyRow
|
||||
key={apiKey.id}
|
||||
appInstanceId={appInstanceId}
|
||||
apiKey={apiKey}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -67,15 +120,7 @@ export function ApiKeyList({ appInstanceId, apiKeys }: {
|
||||
apiKeys: DeveloperApiKeyRow[]
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col divide-y divide-divider-subtle">
|
||||
{apiKeys.map(apiKey => (
|
||||
<ApiKeyRow
|
||||
key={apiKey.id}
|
||||
appInstanceId={appInstanceId}
|
||||
apiKey={apiKey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ApiKeyTable appInstanceId={appInstanceId} apiKeys={apiKeys} />
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -12,8 +13,8 @@ import { CopyPill, EndpointRow } from './common'
|
||||
import { getUrlOrigin } from './url'
|
||||
|
||||
const ACCESS_CHANNEL_SKELETON_SECTIONS = [
|
||||
{ key: 'webapp', className: 'flex flex-col gap-2' },
|
||||
{ key: 'cli', className: 'flex flex-col gap-2 border-t border-divider-subtle pt-3' },
|
||||
{ key: 'webapp' },
|
||||
{ key: 'cli' },
|
||||
]
|
||||
|
||||
function AccessChannelsSwitch({ appInstanceId, checked, disabled }: {
|
||||
@ -39,28 +40,55 @@ function AccessChannelsSwitch({ appInstanceId, checked, disabled }: {
|
||||
|
||||
function AccessChannelsSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
|
||||
{ACCESS_CHANNEL_SKELETON_SECTIONS.map(section => (
|
||||
<div
|
||||
<SkeletonRow
|
||||
key={section.key}
|
||||
className={section.className}
|
||||
className="grid gap-3 border-t border-divider-subtle px-4 py-4 first:border-t-0 lg:grid-cols-[minmax(220px,280px)_minmax(0,1fr)]"
|
||||
>
|
||||
<SkeletonRow className="items-center gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<SkeletonRectangle className="h-3.5 w-24 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-5 w-24 animate-pulse rounded-full" />
|
||||
</SkeletonRow>
|
||||
<SkeletonRectangle className="h-3 w-2/3 animate-pulse" />
|
||||
<SkeletonRow className="flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<SkeletonRectangle className="h-3 w-35 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-8 min-w-65 flex-1 animate-pulse rounded-lg" />
|
||||
<SkeletonRectangle className="my-0 h-8 w-24 animate-pulse rounded-lg" />
|
||||
</SkeletonRow>
|
||||
</div>
|
||||
<SkeletonRectangle className="h-3 w-40 animate-pulse" />
|
||||
</div>
|
||||
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
|
||||
</SkeletonRow>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelStatusBadge() {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<span className="inline-flex h-5 w-fit max-w-full items-center rounded-md bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
|
||||
<span className="truncate">{t('access.channels.followPermission')}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelInfo({ icon, title, description, status }: {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
description: string
|
||||
status: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary">
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="system-sm-medium text-text-primary">{title}</span>
|
||||
{status}
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AccessChannelsSection({
|
||||
appInstanceId,
|
||||
}: {
|
||||
@ -82,7 +110,6 @@ export function AccessChannelsSection({
|
||||
<Section
|
||||
title={t('access.channels.title')}
|
||||
description={t('access.channels.description')}
|
||||
layout="row"
|
||||
action={(
|
||||
accessConfigQuery.isLoading
|
||||
? <SwitchSkeleton />
|
||||
@ -101,90 +128,78 @@ export function AccessChannelsSection({
|
||||
? <SectionState>{t('common.loadFailed')}</SectionState>
|
||||
: runEnabled
|
||||
? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3.5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="system-sm-medium text-text-primary">
|
||||
{t('access.runAccess.webapp')}
|
||||
</div>
|
||||
<span className="inline-flex h-5 items-center rounded-full bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
|
||||
{t('access.channels.followPermission')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t('access.runAccess.webappDesc')}
|
||||
</div>
|
||||
{webappRows.length > 0
|
||||
? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{webappRows.map((row) => {
|
||||
const endpointUrl = webappUrl(row.url)
|
||||
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
|
||||
<div className="grid gap-3 px-4 py-4 lg:grid-cols-[minmax(220px,280px)_minmax(0,1fr)]">
|
||||
<ChannelInfo
|
||||
icon={<span className="i-ri-global-line size-3.5" aria-hidden="true" />}
|
||||
title={t('access.runAccess.webapp')}
|
||||
description={t('access.runAccess.webappDesc')}
|
||||
status={<ChannelStatusBadge />}
|
||||
/>
|
||||
{webappRows.length > 0
|
||||
? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{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>
|
||||
)
|
||||
: (
|
||||
<SectionState>
|
||||
{t('access.runAccess.webappEmpty')}
|
||||
</SectionState>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 border-t border-divider-subtle pt-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="system-sm-medium text-text-primary">
|
||||
{t('access.cli.title')}
|
||||
</div>
|
||||
<span className="inline-flex h-5 items-center rounded-full bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
|
||||
{t('access.channels.followPermission')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t('access.cli.description')}
|
||||
</div>
|
||||
{cliDomain
|
||||
? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CopyPill
|
||||
label={t('access.cli.domain')}
|
||||
value={cliDomain}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<a
|
||||
href={cliDocsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
|
||||
>
|
||||
<span className="i-ri-download-cloud-2-line size-3.5" />
|
||||
{t('access.cli.install')}
|
||||
</a>
|
||||
<a
|
||||
href={cliDocsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
|
||||
>
|
||||
<span className="i-ri-book-open-line size-3.5" />
|
||||
{t('access.cli.docs')}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t('access.cli.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
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>
|
||||
)
|
||||
: (
|
||||
<SectionState>
|
||||
{t('access.runAccess.webappEmpty')}
|
||||
</SectionState>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-3 border-t border-divider-subtle px-4 py-4 lg:grid-cols-[minmax(220px,280px)_minmax(0,1fr)]">
|
||||
<ChannelInfo
|
||||
icon={<span className="i-ri-terminal-box-line size-3.5" aria-hidden="true" />}
|
||||
title={t('access.cli.title')}
|
||||
description={t('access.cli.description')}
|
||||
status={<ChannelStatusBadge />}
|
||||
/>
|
||||
{cliDomain
|
||||
? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CopyPill
|
||||
label={t('access.cli.domain')}
|
||||
value={cliDomain}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<a
|
||||
href={cliDocsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
|
||||
>
|
||||
<span className="i-ri-download-cloud-2-line size-3.5" />
|
||||
{t('access.cli.install')}
|
||||
</a>
|
||||
<a
|
||||
href={cliDocsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
|
||||
>
|
||||
<span className="i-ri-book-open-line size-3.5" />
|
||||
{t('access.cli.docs')}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t('access.cli.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -6,11 +6,11 @@ import type {
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { atom, useAtom, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { Section, SectionState } from '../../common'
|
||||
import { SectionState } from '../../common'
|
||||
import { ApiKeyGenerateMenu, ApiKeyList } from './api-keys'
|
||||
import { CopyPill } from './common'
|
||||
|
||||
@ -19,6 +19,8 @@ type CreatedApiToken = {
|
||||
token: string
|
||||
}
|
||||
|
||||
const createdApiTokenAtom = atom<CreatedApiToken | undefined>(undefined)
|
||||
|
||||
const DEVELOPER_API_KEY_SKELETON_KEYS = ['primary-key', 'secondary-key']
|
||||
|
||||
function permissionEnvironment(row: EnvironmentAccessRow): AppDeployEnvironment | undefined {
|
||||
@ -46,6 +48,50 @@ function DeveloperApiSwitch({ appInstanceId, checked, disabled }: {
|
||||
)
|
||||
}
|
||||
|
||||
export function DeveloperApiHeaderActions({ appInstanceId }: {
|
||||
appInstanceId: string
|
||||
}) {
|
||||
const setCreatedApiToken = useSetAtom(createdApiTokenAtom)
|
||||
const accessConfigQuery = useQuery(consoleQuery.enterprise.appDeployAccessService.getAppInstanceAccess.queryOptions({
|
||||
input: {
|
||||
params: { appInstanceId },
|
||||
},
|
||||
}))
|
||||
const accessConfig = accessConfigQuery.data
|
||||
const apiEnabled = accessConfig?.developerApi?.enabled ?? false
|
||||
const apiKeys = accessConfig?.developerApi?.apiKeys ?? []
|
||||
const environments = accessConfig?.permissions
|
||||
?.map(permissionEnvironment)
|
||||
.filter((environment): environment is AppDeployEnvironment => Boolean(environment)) ?? []
|
||||
|
||||
if (accessConfigQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<SkeletonRectangle className="my-0 h-8 w-32 animate-pulse rounded-lg" />
|
||||
<SwitchSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{apiEnabled && (
|
||||
<ApiKeyGenerateMenu
|
||||
appInstanceId={appInstanceId}
|
||||
environments={environments}
|
||||
apiKeys={apiKeys}
|
||||
onCreatedToken={token => setCreatedApiToken({ appInstanceId, token })}
|
||||
/>
|
||||
)}
|
||||
<DeveloperApiSwitch
|
||||
appInstanceId={appInstanceId}
|
||||
checked={apiEnabled}
|
||||
disabled={accessConfigQuery.isError}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreatedApiTokenCard({ token, onDismiss }: {
|
||||
token: string
|
||||
onDismiss: () => void
|
||||
@ -82,18 +128,18 @@ function CreatedApiTokenCard({ token, onDismiss }: {
|
||||
|
||||
function DeveloperApiSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
|
||||
<SkeletonRow className="items-center justify-between gap-3">
|
||||
<SkeletonRow className="items-center justify-between gap-3 rounded-lg border border-divider-subtle bg-background-default-subtle px-4 py-3">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<SkeletonRectangle className="h-3.5 w-28 animate-pulse" />
|
||||
<SkeletonRectangle className="h-3 w-40 animate-pulse" />
|
||||
</div>
|
||||
<SkeletonRectangle className="my-0 h-8 w-24 animate-pulse rounded-lg" />
|
||||
</SkeletonRow>
|
||||
<div className="flex flex-col divide-y divide-divider-subtle">
|
||||
<div className="flex flex-col gap-2">
|
||||
{DEVELOPER_API_KEY_SKELETON_KEYS.map(key => (
|
||||
<SkeletonRow key={key} className="items-center gap-3 py-1.5">
|
||||
<SkeletonRow key={key} className="items-center gap-3 rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-3">
|
||||
<div className="flex min-w-35 flex-col gap-1.5">
|
||||
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
|
||||
<SkeletonRectangle className="h-2.5 w-18 animate-pulse" />
|
||||
@ -112,7 +158,7 @@ export function DeveloperApiSection({
|
||||
appInstanceId: string
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [createdApiToken, setCreatedApiToken] = useState<CreatedApiToken>()
|
||||
const [createdApiToken, setCreatedApiToken] = useAtom(createdApiTokenAtom)
|
||||
const accessConfigQuery = useQuery(consoleQuery.enterprise.appDeployAccessService.getAppInstanceAccess.queryOptions({
|
||||
input: {
|
||||
params: { appInstanceId },
|
||||
@ -130,51 +176,20 @@ export function DeveloperApiSection({
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={t('access.api.developerTitle')}
|
||||
description={t('access.api.description')}
|
||||
layout="row"
|
||||
action={(
|
||||
accessConfigQuery.isLoading
|
||||
? <SwitchSkeleton />
|
||||
: (
|
||||
<DeveloperApiSwitch
|
||||
appInstanceId={appInstanceId}
|
||||
checked={apiEnabled}
|
||||
disabled={accessConfigQuery.isError}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
>
|
||||
<>
|
||||
{accessConfigQuery.isLoading
|
||||
? <DeveloperApiSkeleton />
|
||||
: accessConfigQuery.isError
|
||||
? <SectionState>{t('common.loadFailed')}</SectionState>
|
||||
: apiEnabled
|
||||
? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
{apiUrl && (
|
||||
<CopyPill
|
||||
label={t('access.api.endpoint')}
|
||||
value={apiUrl}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="system-sm-medium text-text-primary">
|
||||
{t('access.api.backendTitle')}
|
||||
</span>
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{t('access.api.keyList')}
|
||||
</span>
|
||||
</div>
|
||||
<ApiKeyGenerateMenu
|
||||
appInstanceId={appInstanceId}
|
||||
environments={environments}
|
||||
apiKeys={apiKeys}
|
||||
onCreatedToken={token => setCreatedApiToken({ appInstanceId, token })}
|
||||
/>
|
||||
</div>
|
||||
{visibleCreatedApiToken && (
|
||||
<CreatedApiTokenCard
|
||||
token={visibleCreatedApiToken}
|
||||
@ -202,6 +217,6 @@ export function DeveloperApiSection({
|
||||
{t('access.api.disabled')}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -20,11 +20,15 @@ function hasEnvironment(row: EnvironmentAccessRow): row is EnvironmentAccessRow
|
||||
|
||||
function AccessPermissionsSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
|
||||
{ACCESS_PERMISSIONS_SKELETON_KEYS.map(key => (
|
||||
<SkeletonRow key={key} className="flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<SkeletonRectangle className="h-3 w-35 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-8 w-55 animate-pulse rounded-lg" />
|
||||
<SkeletonRow
|
||||
key={key}
|
||||
className="grid gap-3 border-t border-divider-subtle px-4 py-3 first:border-t-0 lg:grid-cols-[minmax(140px,180px)_minmax(190px,230px)_minmax(0,1fr)] lg:items-center"
|
||||
>
|
||||
<SkeletonRectangle className="h-4 w-32 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
|
||||
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
|
||||
</SkeletonRow>
|
||||
))}
|
||||
</div>
|
||||
@ -49,7 +53,7 @@ export function AccessPermissionsSection({
|
||||
<Section
|
||||
title={t('access.permissions.title')}
|
||||
description={t('access.permissions.description')}
|
||||
layout="row"
|
||||
showDivider={false}
|
||||
>
|
||||
{accessConfigQuery.isLoading
|
||||
? <AccessPermissionsSkeleton />
|
||||
@ -62,7 +66,7 @@ export function AccessPermissionsSection({
|
||||
</SectionState>
|
||||
)
|
||||
: (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
|
||||
{permissionRows.map(row => (
|
||||
<EnvironmentPermissionRow
|
||||
key={row.environment.id}
|
||||
|
||||
@ -412,31 +412,53 @@ export function EnvironmentPermissionRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-x-3 gap-y-2 sm:grid-cols-[minmax(88px,108px)_minmax(0,1fr)] lg:grid-cols-[minmax(88px,108px)_minmax(160px,180px)_minmax(0,1fr)]">
|
||||
<span className="pt-1.5 system-xs-regular text-text-tertiary">
|
||||
{environmentName(environment)}
|
||||
</span>
|
||||
<div className="grid gap-3 border-t border-divider-subtle px-4 py-3 first:border-t-0 lg:grid-cols-[minmax(140px,180px)_minmax(190px,230px)_minmax(0,1fr)] lg:items-start">
|
||||
<div className="min-w-0 lg:pt-1.5">
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('access.permissions.col.environment')}
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2">
|
||||
<span className="i-ri-server-line size-3.5 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<span className="min-w-0 truncate system-sm-medium text-text-primary">
|
||||
{environmentName(environment)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('access.permissions.col.permission')}
|
||||
</div>
|
||||
<PermissionPicker
|
||||
value={permissionKind}
|
||||
disabled={controlsDisabled}
|
||||
onChange={handlePermissionChange}
|
||||
/>
|
||||
</div>
|
||||
{permissionKind === 'specific' && (
|
||||
<div className="min-w-0 sm:col-start-2 lg:col-start-3">
|
||||
<SubjectPicker
|
||||
selectedSubjects={subjects}
|
||||
disabled={controlsDisabled}
|
||||
onChange={handleSubjectsChange}
|
||||
/>
|
||||
{subjects.length === 0 && (
|
||||
<span className="mt-1.5 block system-xs-regular text-text-tertiary">
|
||||
{t('access.members.emptySelection')}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('access.permissions.col.subjects')}
|
||||
</div>
|
||||
)}
|
||||
{permissionKind === 'specific'
|
||||
? (
|
||||
<>
|
||||
<SubjectPicker
|
||||
selectedSubjects={subjects}
|
||||
disabled={controlsDisabled}
|
||||
onChange={handleSubjectsChange}
|
||||
/>
|
||||
{subjects.length === 0 && (
|
||||
<span className="mt-1.5 block system-xs-regular text-text-tertiary">
|
||||
{t('access.members.emptySelection')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<div className="flex min-h-8 items-center system-xs-regular text-text-tertiary">
|
||||
{t(`access.permission.${permissionKind}Desc`)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export const INSTANCE_DETAIL_TAB_KEYS = ['overview', 'deploy', 'releases', 'settings'] as const
|
||||
export const INSTANCE_DETAIL_TAB_KEYS = ['overview', 'deploy', 'releases', 'access', 'api', 'settings'] as const
|
||||
|
||||
export type InstanceDetailTabKey = typeof INSTANCE_DETAIL_TAB_KEYS[number]
|
||||
|
||||
|
||||
@ -244,26 +244,29 @@ function DeploymentAccessLinks({ appInstanceId, access, isLoading }: {
|
||||
const links = [
|
||||
access?.accessChannelsEnabled && access.webappUrl
|
||||
? {
|
||||
href: getInstanceTabHref(appInstanceId, 'overview'),
|
||||
key: 'webapp',
|
||||
href: getInstanceTabHref(appInstanceId, 'access'),
|
||||
label: t('card.access.webApp'),
|
||||
icon: 'i-ri-global-line',
|
||||
}
|
||||
: undefined,
|
||||
access?.accessChannelsEnabled && access.cliUrl
|
||||
? {
|
||||
href: getInstanceTabHref(appInstanceId, 'deploy'),
|
||||
key: 'cli',
|
||||
href: getInstanceTabHref(appInstanceId, 'access'),
|
||||
label: t('card.access.cli'),
|
||||
icon: 'i-ri-terminal-box-line',
|
||||
}
|
||||
: undefined,
|
||||
access?.developerApiEnabled && access.apiUrl
|
||||
? {
|
||||
href: getInstanceTabHref(appInstanceId, 'settings'),
|
||||
key: 'api',
|
||||
href: getInstanceTabHref(appInstanceId, 'api'),
|
||||
label: t('card.access.api'),
|
||||
icon: 'i-ri-code-s-slash-line',
|
||||
}
|
||||
: undefined,
|
||||
].filter((link): link is { href: string, label: string, icon: string } => Boolean(link))
|
||||
].filter((link): link is { key: string, href: string, label: string, icon: string } => Boolean(link))
|
||||
|
||||
if (links.length === 0)
|
||||
return <div className="min-w-0 grow" />
|
||||
@ -271,7 +274,7 @@ function DeploymentAccessLinks({ appInstanceId, access, isLoading }: {
|
||||
return (
|
||||
<div className="flex min-w-0 grow items-center gap-2">
|
||||
{links.map(link => (
|
||||
<Tooltip key={link.href}>
|
||||
<Tooltip key={link.key}>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<Link
|
||||
@ -360,12 +363,16 @@ export function InstanceCard({ app }: {
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<p
|
||||
className="mt-2 line-clamp-2 min-h-9 system-xs-regular text-text-tertiary"
|
||||
title={description || t('card.noDescription')}
|
||||
>
|
||||
{description || t('card.noDescription')}
|
||||
</p>
|
||||
description
|
||||
? (
|
||||
<p
|
||||
className="mt-2 line-clamp-2 min-h-9 system-xs-regular text-text-tertiary"
|
||||
title={description}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)
|
||||
: <div className="mt-2 min-h-9" />
|
||||
)}
|
||||
</Link>
|
||||
|
||||
|
||||
@ -14,7 +14,14 @@
|
||||
"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.table.action": "Action",
|
||||
"access.api.table.environment": "Environment",
|
||||
"access.api.table.key": "API key",
|
||||
"access.api.table.name": "Name",
|
||||
"access.api.title": "API",
|
||||
"access.channels.col.channel": "Channel",
|
||||
"access.channels.col.endpoint": "Entry point",
|
||||
"access.channels.col.status": "Status",
|
||||
"access.channels.description": "WebApp and CLI entry points use the access permissions above.",
|
||||
"access.channels.disabled": "Access channels are turned off for this instance.",
|
||||
"access.channels.followPermission": "Follows permissions",
|
||||
@ -55,6 +62,9 @@
|
||||
"access.permission.specificDesc": "Pick groups or individual members",
|
||||
"access.permission.specificUnavailable": "Specific member selection is disabled until real workspace subjects are connected.",
|
||||
"access.permission.updateFailed": "Failed to update access policy.",
|
||||
"access.permissions.col.environment": "Environment",
|
||||
"access.permissions.col.permission": "Access",
|
||||
"access.permissions.col.subjects": "Allowed subjects",
|
||||
"access.permissions.description": "Configure who can access this instance in each deployed environment.",
|
||||
"access.permissions.title": "Access permissions",
|
||||
"access.revoke": "Revoke",
|
||||
@ -418,13 +428,17 @@
|
||||
"status.ready": "Ready",
|
||||
"status.unknown": "Unknown",
|
||||
"subtitle": "Deploy and manage your apps across environments.",
|
||||
"tabs.access.description": "Configure environment permissions and WebApp or CLI access channels.",
|
||||
"tabs.access.name": "Access",
|
||||
"tabs.api.description": "Manage HTTP endpoint access and API keys for this instance.",
|
||||
"tabs.api.name": "Developer API",
|
||||
"tabs.deploy.description": "Environments this instance is deployed to and their current releases.",
|
||||
"tabs.deploy.name": "Deploy",
|
||||
"tabs.overview.description": "Deploy releases and review target environments.",
|
||||
"tabs.overview.name": "Overview",
|
||||
"tabs.releases.description": "All releases for this app. Deploy any release to an environment.",
|
||||
"tabs.releases.name": "Releases",
|
||||
"tabs.settings.description": "Access, API keys, metadata, and backend-managed settings.",
|
||||
"tabs.settings.description": "Metadata and backend-managed settings for this instance.",
|
||||
"tabs.settings.name": "Settings",
|
||||
"title": "App instances",
|
||||
"versions.cancelCreate": "Cancel",
|
||||
|
||||
@ -14,7 +14,14 @@
|
||||
"access.api.newTokenLabel": "密钥",
|
||||
"access.api.newTokenTitle": "API Key 已创建",
|
||||
"access.api.noKeys": "尚无 API 密钥,创建一个即可调用 API。",
|
||||
"access.api.table.action": "操作",
|
||||
"access.api.table.environment": "环境",
|
||||
"access.api.table.key": "API Key",
|
||||
"access.api.table.name": "名称",
|
||||
"access.api.title": "API",
|
||||
"access.channels.col.channel": "渠道",
|
||||
"access.channels.col.endpoint": "入口",
|
||||
"access.channels.col.status": "状态",
|
||||
"access.channels.description": "WebApp 与 CLI 入口遵循上方访问权限。",
|
||||
"access.channels.disabled": "该实例的接入渠道已关闭。",
|
||||
"access.channels.followPermission": "随权限开放",
|
||||
@ -55,6 +62,9 @@
|
||||
"access.permission.specificDesc": "选择指定的分组或单个成员",
|
||||
"access.permission.specificUnavailable": "特定成员暂未启用,需接入真实工作区成员与分组后再开放。",
|
||||
"access.permission.updateFailed": "更新访问策略失败。",
|
||||
"access.permissions.col.environment": "环境",
|
||||
"access.permissions.col.permission": "访问范围",
|
||||
"access.permissions.col.subjects": "授权对象",
|
||||
"access.permissions.description": "配置该实例在每个已部署环境中的访问人员。",
|
||||
"access.permissions.title": "访问权限",
|
||||
"access.revoke": "撤销",
|
||||
@ -418,13 +428,17 @@
|
||||
"status.ready": "就绪",
|
||||
"status.unknown": "未知",
|
||||
"subtitle": "在不同环境中部署和管理你的应用。",
|
||||
"tabs.access.description": "配置环境访问权限,以及 WebApp 或 CLI 接入渠道。",
|
||||
"tabs.access.name": "访问",
|
||||
"tabs.api.description": "管理该实例的 HTTP 调用入口和 API Key。",
|
||||
"tabs.api.name": "开发者 API",
|
||||
"tabs.deploy.description": "该实例已部署的环境及其当前发布版本。",
|
||||
"tabs.deploy.name": "部署",
|
||||
"tabs.overview.description": "部署发布版本并查看目标环境。",
|
||||
"tabs.overview.name": "概览",
|
||||
"tabs.releases.description": "此应用的所有发布版本,可将任一发布版本部署到环境。",
|
||||
"tabs.releases.name": "发布版本",
|
||||
"tabs.settings.description": "管理接入、API Key、元数据和后端托管设置。",
|
||||
"tabs.settings.description": "管理该实例的元数据和后端托管设置。",
|
||||
"tabs.settings.name": "设置",
|
||||
"title": "应用实例",
|
||||
"versions.cancelCreate": "取消",
|
||||
|
||||
Reference in New Issue
Block a user