refactor(overview): update imports and enhance chart and card views with new types and tests

- Changed import paths for app card and chart types to use the new app-chart-types module.
- Refactored state initialization in ChartView for improved readability.
- Added unit tests for CardView, ChartView, and LongTimeRangePicker components to ensure functionality and reliability.
- Introduced new test cases for time range picker and tracing components to enhance test coverage.
This commit is contained in:
CodingOnStar
2026-03-31 13:24:54 +08:00
parent d99ca80f48
commit 6c0123c5d2
40 changed files with 4710 additions and 1032 deletions

View File

@@ -0,0 +1,272 @@
import type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { AppModeEnum } from '@/types/app'
import CardView from '../card-view'
const mockToast = vi.fn()
const mockFetchAppDetail = vi.fn()
const mockUpdateAppSiteStatus = vi.fn()
const mockUpdateAppSiteConfig = vi.fn()
const mockUpdateAppSiteAccessToken = vi.fn()
const mockSocketEmit = vi.fn()
const mockGetSocket = vi.fn()
const mockOnAppStateUpdate = vi.fn()
const cardState = vi.hoisted(() => ({
appDetail: null as null | { id: string, mode: string, name?: string },
workflow: null as null | { graph?: { nodes?: Array<{ data?: { type?: string } }> } },
setAppDetail: vi.fn(),
collaborationCallback: null as null | (() => Promise<void>),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail: typeof cardState.appDetail, setAppDetail: typeof cardState.setAppDetail }) => unknown) => selector({
appDetail: cardState.appDetail,
setAppDetail: cardState.setAppDetail,
}),
}))
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: () => ({ data: cardState.workflow }),
}))
vi.mock('@/app/components/base/loading', () => ({
default: () => <div>loading</div>,
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: (...args: unknown[]) => mockToast(...args),
}))
vi.mock('@/app/components/app/overview/app-card', () => ({
default: ({
cardType,
triggerModeDisabled,
onChangeStatus,
onGenerateCode,
onSaveSiteConfig,
}: {
cardType: string
triggerModeDisabled?: boolean
onChangeStatus?: (value: boolean) => void
onGenerateCode?: () => void
onSaveSiteConfig?: (params: Record<string, string>) => void
}) => (
<div>
<div>{`app-card:${cardType}:${triggerModeDisabled ? 'disabled' : 'enabled'}`}</div>
{onChangeStatus && <button type="button" onClick={() => onChangeStatus(true)}>{`toggle:${cardType}`}</button>}
{onGenerateCode && <button type="button" onClick={onGenerateCode}>{`generate:${cardType}`}</button>}
{onSaveSiteConfig && <button type="button" onClick={() => onSaveSiteConfig({ title: 'site-title' })}>{`save-config:${cardType}`}</button>}
</div>
),
}))
vi.mock('@/app/components/app/overview/trigger-card', () => ({
default: ({ onToggleResult }: { onToggleResult: (error: Error | null, message?: string) => void }) => (
<button type="button" onClick={() => onToggleResult(null, 'generatedSuccessfully')}>trigger-card</button>
),
}))
vi.mock('@/app/components/tools/mcp/mcp-service-card', () => ({
default: ({ triggerModeDisabled }: { triggerModeDisabled?: boolean }) => <div>{`mcp-card:${triggerModeDisabled ? 'disabled' : 'enabled'}`}</div>,
}))
vi.mock('@/app/components/workflow/types', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/types')>()
return {
...actual,
isTriggerNode: (type: string) => type === 'trigger',
}
})
vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({
webSocketClient: {
getSocket: (...args: unknown[]) => mockGetSocket(...args),
},
}))
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
collaborationManager: {
onAppStateUpdate: (...args: unknown[]) => mockOnAppStateUpdate(...args),
},
}))
vi.mock('@/service/apps', () => ({
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
updateAppSiteStatus: (...args: unknown[]) => mockUpdateAppSiteStatus(...args),
updateAppSiteConfig: (...args: unknown[]) => mockUpdateAppSiteConfig(...args),
updateAppSiteAccessToken: (...args: unknown[]) => mockUpdateAppSiteAccessToken(...args),
}))
const createApp = (overrides: Partial<App> = {}): App => ({
id: 'app-1',
mode: 'chat',
name: 'App 1',
description: '',
icon: '',
icon_background: '',
icon_type: 'emoji',
enable_site: false,
enable_api: false,
created_at: 0,
updated_at: 0,
...overrides,
} as App)
describe('OverviewRouteCardView', () => {
beforeEach(() => {
vi.clearAllMocks()
cardState.appDetail = null
cardState.workflow = null
cardState.collaborationCallback = null
cardState.setAppDetail.mockReset()
mockFetchAppDetail.mockResolvedValue(createApp({ mode: AppModeEnum.CHAT }))
mockUpdateAppSiteStatus.mockResolvedValue(createApp())
mockUpdateAppSiteConfig.mockResolvedValue(createApp())
mockUpdateAppSiteAccessToken.mockResolvedValue({ access_token: 'token-1' })
mockGetSocket.mockReturnValue({ emit: mockSocketEmit })
mockOnAppStateUpdate.mockImplementation((callback: () => Promise<void>) => {
cardState.collaborationCallback = callback
return vi.fn()
})
localStorage.clear()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should render loading when app details are unavailable', () => {
render(<CardView appId="app-1" />)
expect(screen.getByText('loading')).toBeInTheDocument()
})
it('should render workflow trigger mode cards inside the panel', () => {
cardState.appDetail = { id: 'app-1', mode: AppModeEnum.WORKFLOW }
cardState.workflow = { graph: { nodes: [{ data: { type: 'trigger' } }] } }
render(<CardView appId="app-1" isInPanel={true} />)
expect(screen.getByText('app-card:webapp:disabled')).toBeInTheDocument()
expect(screen.getByText('app-card:api:disabled')).toBeInTheDocument()
expect(screen.getByText('mcp-card:disabled')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'trigger-card' })).toBeInTheDocument()
})
it('should disable app cards while workflow details are still loading', () => {
cardState.appDetail = { id: 'app-1', mode: AppModeEnum.WORKFLOW }
cardState.workflow = null
render(<CardView appId="app-1" isInPanel={true} />)
expect(screen.getByText('app-card:webapp:disabled')).toBeInTheDocument()
expect(screen.getByText('app-card:api:disabled')).toBeInTheDocument()
expect(screen.getByText('mcp-card:disabled')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'trigger-card' })).toBeInTheDocument()
})
it('should call app actions, refresh details, and emit collaboration events on success', async () => {
cardState.appDetail = { id: 'app-1', mode: AppModeEnum.CHAT }
render(<CardView appId="app-1" />)
fireEvent.click(screen.getByRole('button', { name: 'toggle:webapp' }))
fireEvent.click(screen.getByRole('button', { name: 'toggle:api' }))
fireEvent.click(screen.getByRole('button', { name: 'save-config:webapp' }))
fireEvent.click(screen.getByRole('button', { name: 'generate:webapp' }))
await waitFor(() => {
expect(mockUpdateAppSiteStatus).toHaveBeenCalledWith({
url: '/apps/app-1/site-enable',
body: { enable_site: true },
})
})
expect(mockUpdateAppSiteStatus).toHaveBeenCalledWith({
url: '/apps/app-1/api-enable',
body: { enable_api: true },
})
expect(mockUpdateAppSiteConfig).toHaveBeenCalledWith({
url: '/apps/app-1/site',
body: { title: 'site-title' },
})
expect(mockUpdateAppSiteAccessToken).toHaveBeenCalledWith({
url: '/apps/app-1/site/access-token-reset',
})
expect(localStorage.getItem('needRefreshAppList')).toBe('1')
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(cardState.setAppDetail).toHaveBeenCalled()
expect(mockSocketEmit).toHaveBeenCalledWith('collaboration_event', expect.objectContaining({
type: 'app_state_update',
}))
expect(mockToast).toHaveBeenCalledWith(expect.stringMatching(/common\.actionMsg\./), expect.objectContaining({ type: 'success' }))
})
it('should show an error toast when site status updates fail', async () => {
cardState.appDetail = { id: 'app-1', mode: AppModeEnum.CHAT }
mockUpdateAppSiteStatus.mockRejectedValueOnce(new Error('failed'))
render(<CardView appId="app-1" />)
fireEvent.click(screen.getByRole('button', { name: 'toggle:webapp' }))
await waitFor(() => {
expect(mockToast).toHaveBeenCalledWith(expect.stringMatching(/common\.actionMsg\./), { type: 'error' })
})
})
it('should log fetch errors when refreshing app details after successful mutations', async () => {
const fetchError = new Error('fetch failed')
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
cardState.appDetail = { id: 'app-1', mode: AppModeEnum.CHAT }
mockFetchAppDetail.mockRejectedValue(fetchError)
render(<CardView appId="app-1" />)
fireEvent.click(screen.getByRole('button', { name: 'toggle:webapp' }))
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith(fetchError)
})
})
it('should skip collaboration subscriptions when appId is missing', () => {
cardState.appDetail = { id: 'app-1', mode: AppModeEnum.CHAT }
render(<CardView appId="" />)
expect(mockOnAppStateUpdate).not.toHaveBeenCalled()
})
it('should refresh details when the collaboration listener fires', async () => {
cardState.appDetail = { id: 'app-1', mode: AppModeEnum.CHAT }
const { unmount } = render(<CardView appId="app-1" />)
await waitFor(() => expect(mockOnAppStateUpdate).toHaveBeenCalled())
await cardState.collaborationCallback?.()
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
unmount()
})
it('should log collaboration refresh errors when error logging fails inside the refresh helper', async () => {
const refreshError = new Error('refresh failed')
const loggerError = new Error('logger failed')
const consoleErrorSpy = vi.spyOn(console, 'error')
.mockImplementationOnce(() => {
throw loggerError
})
.mockImplementation(() => {})
cardState.appDetail = { id: 'app-1', mode: AppModeEnum.CHAT }
mockFetchAppDetail.mockRejectedValueOnce(refreshError)
render(<CardView appId="app-1" />)
await waitFor(() => expect(mockOnAppStateUpdate).toHaveBeenCalled())
await cardState.collaborationCallback?.()
expect(consoleErrorSpy).toHaveBeenNthCalledWith(1, refreshError)
expect(consoleErrorSpy).toHaveBeenNthCalledWith(2, 'app state update failed:', loggerError)
})
})

View File

@@ -0,0 +1,127 @@
import { fireEvent, render, screen } from '@testing-library/react'
import ChartView from '../chart-view'
const mockState = vi.hoisted(() => ({
appDetail: null as null | { mode: string },
isCloudEdition: true,
}))
vi.mock('@/config', () => ({
get IS_CLOUD_EDITION() {
return mockState.isCloudEdition
},
}))
vi.mock('@/app/components/app/log/filter', () => ({
TIME_PERIOD_MAPPING: {
default: { value: -1, name: 'allTime' },
},
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail: typeof mockState.appDetail }) => unknown) => selector({
appDetail: mockState.appDetail,
}),
}))
vi.mock('../time-range-picker', () => ({
default: ({ onSelect }: { onSelect: (payload: { name: string, query: { start: string, end: string } }) => void }) => (
<button
type="button"
onClick={() => onSelect({ name: 'cloud-range', query: { start: '2026-03-01 00:00', end: '2026-03-01 23:59' } })}
>
cloud-picker
</button>
),
}))
vi.mock('../long-time-range-picker', () => ({
default: ({ onSelect }: { onSelect: (payload: { name: string, query?: { start: string, end: string } }) => void }) => (
<button
type="button"
onClick={() => onSelect({ name: 'long-range', query: undefined })}
>
long-picker
</button>
),
}))
vi.mock('@/app/components/app/overview/chart/metrics', () => {
const createChart = (label: string) => ({
default: ({ id, period }: { id: string, period: { name: string } }) => <div>{`${label}:${id}:${period.name}`}</div>,
}).default
return {
ConversationsChart: createChart('conversations'),
EndUsersChart: createChart('end-users'),
AvgSessionInteractions: createChart('avg-session'),
AvgResponseTime: createChart('avg-response'),
TokenPerSecond: createChart('tps'),
UserSatisfactionRate: createChart('satisfaction'),
CostChart: createChart('cost'),
MessagesChart: createChart('messages'),
WorkflowMessagesChart: createChart('workflow-messages'),
WorkflowDailyTerminalsChart: createChart('workflow-terminals'),
WorkflowCostChart: createChart('workflow-cost'),
AvgUserInteractions: createChart('avg-user'),
}
})
describe('OverviewRouteChartView', () => {
beforeEach(() => {
vi.clearAllMocks()
mockState.appDetail = null
mockState.isCloudEdition = true
})
it('should render nothing when app detail is unavailable', () => {
const { container } = render(<ChartView appId="app-1" headerRight={<div>header</div>} />)
expect(container).toBeEmptyDOMElement()
})
it('should render cloud chat metrics and update period from the short picker', () => {
mockState.appDetail = { mode: 'chat' }
render(<ChartView appId="app-1" headerRight={<div>header-right</div>} />)
expect(screen.getByText('common.appMenus.overview')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'cloud-picker' })).toBeInTheDocument()
expect(screen.getByText('conversations:app-1:appLog.filter.period.today')).toBeInTheDocument()
expect(screen.getByText('avg-session:app-1:appLog.filter.period.today')).toBeInTheDocument()
expect(screen.getByText('messages:app-1:appLog.filter.period.today')).toBeInTheDocument()
expect(screen.getByText('header-right')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'cloud-picker' }))
expect(screen.getByText('conversations:app-1:cloud-range')).toBeInTheDocument()
expect(screen.queryByText(/workflow-messages/)).not.toBeInTheDocument()
})
it('should render completion metrics without chat-only charts', () => {
mockState.appDetail = { mode: 'completion' }
render(<ChartView appId="app-2" headerRight={<div>header-right</div>} />)
expect(screen.getByText('avg-response:app-2:appLog.filter.period.today')).toBeInTheDocument()
expect(screen.queryByText(/avg-session:app-2/)).not.toBeInTheDocument()
expect(screen.queryByText(/messages:app-2/)).not.toBeInTheDocument()
})
it('should render workflow metrics and use the long-range picker outside cloud mode', () => {
mockState.appDetail = { mode: 'workflow' }
mockState.isCloudEdition = false
render(<ChartView appId="app-3" headerRight={<div>header-right</div>} />)
expect(screen.getByRole('button', { name: 'long-picker' })).toBeInTheDocument()
expect(screen.getByText('workflow-messages:app-3:appLog.filter.period.last7days')).toBeInTheDocument()
expect(screen.getByText('workflow-cost:app-3:appLog.filter.period.last7days')).toBeInTheDocument()
expect(screen.queryByText(/conversations:app-3/)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'long-picker' }))
expect(screen.getByText('workflow-messages:app-3:long-range')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,68 @@
import type { Item } from '@/app/components/base/select'
import { fireEvent, render, screen } from '@testing-library/react'
import dayjs from 'dayjs'
import LongTimeRangePicker from '../long-time-range-picker'
const simpleSelectState = vi.hoisted(() => ({
items: [] as Item[],
onSelect: null as null | ((item: Item) => void),
}))
vi.mock('@/app/components/base/select', () => ({
SimpleSelect: ({
items,
onSelect,
}: {
items: Item[]
onSelect: (item: Item) => void
}) => {
simpleSelectState.items = items
simpleSelectState.onSelect = onSelect
return (
<div>
<button
type="button"
onClick={() => onSelect({ value: 'missing-period', name: '' })}
>
trigger-fallback-period
</button>
</div>
)
},
}))
describe('OverviewRouteLongTimeRangePickerFallbackBranches', () => {
beforeEach(() => {
vi.clearAllMocks()
simpleSelectState.items = []
simpleSelectState.onSelect = null
})
it('should keep using the fallback callback payload when the select callback receives an unmapped item', () => {
const onSelect = vi.fn()
render(
<LongTimeRangePicker
periodMapping={{
'-1': { value: -1, name: 'allTime' },
'2': { value: 30, name: 'last30days' },
}}
onSelect={onSelect}
queryDateFormat="YYYY-MM-DD"
/>,
)
expect(simpleSelectState.items).toHaveLength(2)
fireEvent.click(screen.getByRole('button', { name: 'trigger-fallback-period' }))
expect(onSelect).toHaveBeenCalledWith({
name: 'appLog.filter.period.allTime',
query: {
start: dayjs().subtract(-1, 'day').startOf('day').format('YYYY-MM-DD'),
end: dayjs().endOf('day').format('YYYY-MM-DD'),
},
})
})
})

View File

@@ -0,0 +1,97 @@
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import dayjs from 'dayjs'
import LongTimeRangePicker from '../long-time-range-picker'
const periodMapping = {
'-1': { value: -1, name: 'allTime' as const },
'0': { value: 0, name: 'today' as const },
'2': { value: 30, name: 'last30days' as const },
}
describe('OverviewRouteLongTimeRangePicker', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the translated default option', () => {
render(
<LongTimeRangePicker
periodMapping={periodMapping}
onSelect={vi.fn()}
queryDateFormat="YYYY-MM-DD"
/>,
)
expect(screen.getByText('appLog.filter.period.last30days')).toBeInTheDocument()
})
it('should emit an all-time selection without query params', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<LongTimeRangePicker
periodMapping={periodMapping}
onSelect={onSelect}
queryDateFormat="YYYY-MM-DD"
/>,
)
await user.click(screen.getByRole('button'))
await user.click(within(screen.getByRole('listbox')).getByText('appLog.filter.period.allTime'))
expect(onSelect).toHaveBeenCalledWith({
name: 'appLog.filter.period.allTime',
query: undefined,
})
})
it('should emit a today selection with start and end of day', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<LongTimeRangePicker
periodMapping={periodMapping}
onSelect={onSelect}
queryDateFormat="YYYY-MM-DD"
/>,
)
await user.click(screen.getByRole('button'))
await user.click(within(screen.getByRole('listbox')).getByText('appLog.filter.period.today'))
expect(onSelect).toHaveBeenCalledWith({
name: 'appLog.filter.period.today',
query: {
start: dayjs().startOf('day').format('YYYY-MM-DD'),
end: dayjs().endOf('day').format('YYYY-MM-DD'),
},
})
})
it('should emit a relative time window for normal period selections', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<LongTimeRangePicker
periodMapping={periodMapping}
onSelect={onSelect}
queryDateFormat="YYYY-MM-DD"
/>,
)
await user.click(screen.getByRole('button'))
await user.click(within(screen.getByRole('listbox')).getByText('appLog.filter.period.last30days'))
expect(onSelect).toHaveBeenCalledWith({
name: 'appLog.filter.period.last30days',
query: {
start: dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD'),
end: dayjs().endOf('day').format('YYYY-MM-DD'),
},
})
})
})

View File

@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react'
import OverviewPage from '../page'
vi.mock('@/app/components/app/overview/apikey-info-panel', () => ({
default: () => <div>apikey-info-panel</div>,
}))
vi.mock('../chart-view', () => ({
default: ({ appId, headerRight }: { appId: string, headerRight: React.ReactNode }) => (
<div>
<div>{`chart-view:${appId}`}</div>
<div>{headerRight}</div>
</div>
),
}))
vi.mock('../tracing/panel', () => ({
default: () => <div>tracing-panel</div>,
}))
describe('OverviewRoutePage', () => {
it('should resolve params and compose the overview page layout', async () => {
render(await OverviewPage({ params: Promise.resolve({ appId: 'app-123' }) }))
expect(screen.getByText('apikey-info-panel')).toBeInTheDocument()
expect(screen.getByText('chart-view:app-123')).toBeInTheDocument()
expect(screen.getByText('tracing-panel')).toBeInTheDocument()
})
})

View File

@@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import type { IAppCardProps } from '@/app/components/app/overview/app-card'
import type { IAppCardProps } from '@/app/components/app/overview/app-card/types'
import type { BlockEnum } from '@/app/components/workflow/types'
import type { UpdateAppSiteCodeResponse } from '@/models/app'
import type { App } from '@/types/app'

View File

@@ -1,5 +1,5 @@
'use client'
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
import type { PeriodParams } from '@/app/components/app/overview/chart/types'
import type { I18nKeysByPrefix } from '@/types/i18n'
import dayjs from 'dayjs'
import quarterOfYear from 'dayjs/plugin/quarterOfYear'
@@ -7,7 +7,7 @@ import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { TIME_PERIOD_MAPPING as LONG_TIME_PERIOD_MAPPING } from '@/app/components/app/log/filter'
import { AvgResponseTime, AvgSessionInteractions, AvgUserInteractions, ConversationsChart, CostChart, EndUsersChart, MessagesChart, TokenPerSecond, UserSatisfactionRate, WorkflowCostChart, WorkflowDailyTerminalsChart, WorkflowMessagesChart } from '@/app/components/app/overview/app-chart'
import { AvgResponseTime, AvgSessionInteractions, AvgUserInteractions, ConversationsChart, CostChart, EndUsersChart, MessagesChart, TokenPerSecond, UserSatisfactionRate, WorkflowCostChart, WorkflowDailyTerminalsChart, WorkflowMessagesChart } from '@/app/components/app/overview/chart/metrics'
import { useStore as useAppStore } from '@/app/components/app/store'
import { IS_CLOUD_EDITION } from '@/config'
import LongTimeRangePicker from './long-time-range-picker'
@@ -37,10 +37,11 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) {
const appDetail = useAppStore(state => state.appDetail)
const isChatApp = appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow'
const isWorkflow = appDetail?.mode === 'workflow'
const [period, setPeriod] = useState<PeriodParams>(IS_CLOUD_EDITION
? { name: t('filter.period.today', { ns: 'appLog' }), query: { start: today.startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } }
: { name: t('filter.period.last7days', { ns: 'appLog' }), query: { start: today.subtract(7, 'day').startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } },
)
const [period, setPeriod] = useState<PeriodParams>(() => (
IS_CLOUD_EDITION
? { name: t('filter.period.today', { ns: 'appLog' }), query: { start: today.startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } }
: { name: t('filter.period.last7days', { ns: 'appLog' }), query: { start: today.subtract(7, 'day').startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } }
))
if (!appDetail)
return null

View File

@@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
import type { PeriodParams } from '@/app/components/app/overview/chart/types'
import type { Item } from '@/app/components/base/select'
import type { I18nKeysByPrefix } from '@/types/i18n'
import dayjs from 'dayjs'

View File

@@ -0,0 +1,78 @@
import type { Dayjs } from 'dayjs'
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import dayjs from 'dayjs'
import DatePicker from '../date-picker'
const pickerState = vi.hoisted(() => ({
disabledChecks: {} as Record<string, boolean>,
startValue: undefined as Dayjs | undefined,
}))
vi.mock('@/app/components/base/date-and-time-picker/date-picker', () => ({
default: ({
value,
renderTrigger,
getIsDateDisabled,
}: {
value?: Dayjs
renderTrigger: (props: {
value?: Dayjs
handleClickTrigger: () => void
isOpen: boolean
}) => ReactNode
getIsDateDisabled: (date: Dayjs) => boolean
}) => {
const id = Object.keys(pickerState.disabledChecks).length === 0 ? 'start' : 'end'
const triggerValue = id === 'start' ? undefined : value
if (id === 'start')
pickerState.startValue = value
const trigger = renderTrigger({
value: triggerValue,
handleClickTrigger: () => {},
isOpen: false,
})
pickerState.disabledChecks[id] = id === 'start'
? getIsDateDisabled(dayjs().add(1, 'day'))
: getIsDateDisabled(dayjs(pickerState.startValue).add(30, 'day'))
return (
<div>
<div data-testid={`${id}-trigger`}>{trigger}</div>
</div>
)
},
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
vi.mock('@/utils/format', async () => {
const actual = await vi.importActual<typeof import('@/utils/format')>('@/utils/format')
return {
...actual,
formatToLocalTime: (value: Dayjs, _locale: string, format: string) => value.format(format),
}
})
describe('OverviewRouteDatePickerFallbackBranches', () => {
beforeEach(() => {
vi.clearAllMocks()
pickerState.disabledChecks = {}
pickerState.startValue = undefined
})
it('should render an empty trigger for missing values and allow the 30-day end boundary', () => {
const start = dayjs('2026-01-01')
const end = dayjs('2026-01-10')
render(<DatePicker start={start} end={end} onStartChange={vi.fn()} onEndChange={vi.fn()} />)
expect(screen.getByTestId('start-trigger')).toHaveTextContent(/^$/)
expect(screen.getByTestId('end-trigger')).toHaveTextContent('Jan 10')
expect(pickerState.disabledChecks.start).toBe(true)
expect(pickerState.disabledChecks.end).toBe(false)
})
})

View File

@@ -0,0 +1,88 @@
import type { Dayjs } from 'dayjs'
import { fireEvent, render, screen } from '@testing-library/react'
import dayjs from 'dayjs'
import DatePicker from '../date-picker'
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
vi.mock('@/utils/format', async () => {
const actual = await vi.importActual<typeof import('@/utils/format')>('@/utils/format')
return {
...actual,
formatToLocalTime: (value: Dayjs, _locale: string, format: string) => value.format(format),
}
})
const renderComponent = (overrides: Partial<React.ComponentProps<typeof DatePicker>> = {}) => {
const props: React.ComponentProps<typeof DatePicker> = {
start: dayjs().subtract(1, 'day'),
end: dayjs(),
onStartChange: vi.fn(),
onEndChange: vi.fn(),
...overrides,
}
render(<DatePicker {...props} />)
return props
}
describe('OverviewRouteDatePicker', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render formatted start and end triggers', () => {
const start = dayjs('2026-01-10')
const end = dayjs('2026-01-15')
renderComponent({ start, end })
expect(screen.getByText('Jan 10')).toBeInTheDocument()
expect(screen.getByText('Jan 15')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should open the start picker and notify when a new start date is selected', () => {
const onStartChange = vi.fn()
const end = dayjs()
renderComponent({
start: end.subtract(1, 'day'),
end,
onStartChange,
})
fireEvent.click(screen.getByText(end.subtract(1, 'day').format('MMM D')))
expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: `${end.date()}` }))
expect(onStartChange).toHaveBeenCalledTimes(1)
expect(dayjs(onStartChange.mock.calls[0][0]).isSame(end, 'date')).toBe(true)
})
it('should open the end picker and notify when a new end date is selected', () => {
const onEndChange = vi.fn()
const start = dayjs().subtract(1, 'day')
renderComponent({
start,
end: dayjs(),
onEndChange,
})
fireEvent.click(screen.getByText(dayjs().format('MMM D')))
expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: `${start.date()}` }))
expect(onEndChange).toHaveBeenCalledTimes(1)
expect(dayjs(onEndChange.mock.calls[0][0]).isSame(start, 'date')).toBe(true)
})
})
})

View File

@@ -0,0 +1,144 @@
import type { Dayjs } from 'dayjs'
import { fireEvent, render, screen } from '@testing-library/react'
import dayjs from 'dayjs'
import TimeRangePicker from '../index'
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
vi.mock('@/utils/format', async () => {
const actual = await vi.importActual<typeof import('@/utils/format')>('@/utils/format')
return {
...actual,
formatToLocalTime: (value: Dayjs, _locale: string, format: string) => value.format(format),
}
})
vi.mock('../date-picker', () => ({
default: ({
start,
end,
onStartChange,
onEndChange,
}: {
start: Dayjs
end: Dayjs
onStartChange: (date?: Dayjs) => void
onEndChange: (date?: Dayjs) => void
}) => (
<div>
<div data-testid="date-picker-range">{`${start.format('MMM D')} - ${end.format('MMM D')}`}</div>
<button type="button" onClick={() => onStartChange(undefined)}>skip-start</button>
<button type="button" onClick={() => onStartChange(start)}>same-start</button>
<button type="button" onClick={() => onStartChange(start.subtract(1, 'day'))}>change-start</button>
<button type="button" onClick={() => onEndChange(end)}>same-end</button>
<button type="button" onClick={() => onEndChange(end.add(1, 'day'))}>change-end</button>
</div>
),
}))
vi.mock('../range-selector', () => ({
default: ({
isCustomRange,
onSelect,
}: {
isCustomRange: boolean
onSelect: (payload: { name: string, query: { start: Dayjs, end: Dayjs } }) => void
}) => (
<div>
<div data-testid="range-mode">{isCustomRange ? 'custom' : 'preset'}</div>
<button
type="button"
onClick={() => onSelect({
name: 'appLog.filter.period.last7days',
query: {
start: dayjs('2026-03-01'),
end: dayjs('2026-03-08'),
},
})}
>
select-range
</button>
</div>
),
}))
describe('OverviewRouteTimeRangePicker', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should format a preset range selection and keep preset mode', () => {
const onSelect = vi.fn()
render(
<TimeRangePicker
ranges={[{ value: 7, name: 'last7days' }]}
onSelect={onSelect}
queryDateFormat="YYYY-MM-DD"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'select-range' }))
expect(onSelect).toHaveBeenCalledWith({
name: 'appLog.filter.period.last7days',
query: {
start: '2026-03-01',
end: '2026-03-08',
},
})
expect(screen.getByTestId('range-mode')).toHaveTextContent('preset')
})
it('should ignore empty or unchanged date updates', () => {
const onSelect = vi.fn()
render(
<TimeRangePicker
ranges={[{ value: 7, name: 'last7days' }]}
onSelect={onSelect}
queryDateFormat="YYYY-MM-DD"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'skip-start' }))
fireEvent.click(screen.getByRole('button', { name: 'same-start' }))
fireEvent.click(screen.getByRole('button', { name: 'same-end' }))
expect(onSelect).not.toHaveBeenCalled()
expect(screen.getByTestId('range-mode')).toHaveTextContent('preset')
})
it('should format custom date changes and switch to custom mode', () => {
const onSelect = vi.fn()
render(
<TimeRangePicker
ranges={[{ value: 7, name: 'last7days' }]}
onSelect={onSelect}
queryDateFormat="YYYY-MM-DD"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'change-start' }))
fireEvent.click(screen.getByRole('button', { name: 'change-end' }))
expect(onSelect).toHaveBeenNthCalledWith(1, {
name: `${dayjs().subtract(1, 'day').format('MMM D')} - ${dayjs().format('MMM D')}`,
query: {
start: dayjs().subtract(1, 'day').format('YYYY-MM-DD'),
end: dayjs().format('YYYY-MM-DD'),
},
})
expect(onSelect).toHaveBeenNthCalledWith(2, {
name: `${dayjs().subtract(1, 'day').format('MMM D')} - ${dayjs().add(1, 'day').format('MMM D')}`,
query: {
start: dayjs().subtract(1, 'day').format('YYYY-MM-DD'),
end: dayjs().add(1, 'day').format('YYYY-MM-DD'),
},
})
expect(screen.getByTestId('range-mode')).toHaveTextContent('custom')
})
})

View File

@@ -0,0 +1,73 @@
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import dayjs from 'dayjs'
import RangeSelector from '../range-selector'
const ranges = [
{ value: 0, name: 'today' as const },
{ value: 7, name: 'last7days' as const },
]
describe('OverviewRouteRangeSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render the custom range label when custom mode is active', () => {
render(
<RangeSelector
isCustomRange={true}
ranges={ranges}
onSelect={vi.fn()}
/>,
)
expect(screen.getByText('appLog.filter.period.custom')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should select the today option and emit a single-day range', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<RangeSelector
isCustomRange={false}
ranges={ranges}
onSelect={onSelect}
/>,
)
await user.click(screen.getByRole('button'))
await user.click(within(screen.getByRole('listbox')).getByText('appLog.filter.period.today'))
expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect.mock.calls[0][0].name).toBe('appLog.filter.period.today')
expect(onSelect.mock.calls[0][0].query.start.isSame(dayjs().startOf('day'))).toBe(true)
expect(onSelect.mock.calls[0][0].query.end.isSame(dayjs().endOf('day'))).toBe(true)
})
it('should select a relative range and emit the computed query window', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<RangeSelector
isCustomRange={false}
ranges={ranges}
onSelect={onSelect}
/>,
)
await user.click(screen.getByRole('button'))
await user.click(within(screen.getByRole('listbox')).getByText('appLog.filter.period.last7days'))
expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect.mock.calls[0][0].name).toBe('appLog.filter.period.last7days')
expect(onSelect.mock.calls[0][0].query.start.isSame(dayjs().subtract(7, 'day').startOf('day'))).toBe(true)
expect(onSelect.mock.calls[0][0].query.end.isSame(dayjs().endOf('day'))).toBe(true)
})
})
})

View File

@@ -1,12 +1,11 @@
'use client'
import type { Dayjs } from 'dayjs'
import type { FC } from 'react'
import type { PeriodParams, PeriodParamsWithTimeRange } from '@/app/components/app/overview/app-chart'
import type { PeriodParams, PeriodParamsWithTimeRange } from '@/app/components/app/overview/chart/types'
import type { I18nKeysByPrefix } from '@/types/i18n'
import dayjs from 'dayjs'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { HourglassShape } from '@/app/components/base/icons/src/vender/other'
import { useLocale } from '@/context/i18n'
import { formatToLocalTime } from '@/utils/format'
import DatePicker from './date-picker'
@@ -80,7 +79,7 @@ const TimeRangePicker: FC<Props> = ({
ranges={ranges}
onSelect={handleRangeChange}
/>
<HourglassShape className="h-3.5 w-2 text-components-input-bg-normal" />
<span className="i-custom-vender-other-hourglass-shape h-3.5 w-2 text-components-input-bg-normal" aria-hidden="true" />
<DatePicker
start={start}
end={end}

View File

@@ -1,9 +1,8 @@
'use client'
import type { FC } from 'react'
import type { PeriodParamsWithTimeRange, TimeRange } from '@/app/components/app/overview/app-chart'
import type { PeriodParamsWithTimeRange, TimeRange } from '@/app/components/app/overview/chart/types'
import type { Item } from '@/app/components/base/select'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
import dayjs from 'dayjs'
import * as React from 'react'
import { useCallback } from 'react'
@@ -46,10 +45,10 @@ const RangeSelector: FC<Props> = ({
return (
<div className={cn('flex h-8 cursor-pointer items-center space-x-1.5 rounded-lg bg-components-input-bg-normal pl-3 pr-2', isOpen && 'bg-state-base-hover-alt')}>
<div className="text-components-input-text-filled system-sm-regular">{isCustomRange ? t('filter.period.custom', { ns: 'appLog' }) : item?.name}</div>
<RiArrowDownSLine className={cn('size-4 text-text-quaternary', isOpen && 'text-text-secondary')} />
<span className={cn('i-ri-arrow-down-s-line size-4 text-text-quaternary', isOpen && 'text-text-secondary')} aria-hidden="true" />
</div>
)
}, [isCustomRange])
}, [isCustomRange, t])
const renderOption = useCallback(({ item, selected }: { item: Item, selected: boolean }) => {
return (
@@ -60,7 +59,7 @@ const RangeSelector: FC<Props> = ({
'absolute left-2 top-[9px] flex items-center text-text-accent',
)}
>
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
<span className="i-ri-check-line h-4 w-4" aria-hidden="true" />
</span>
)}
<span className={cn('block truncate system-md-regular')}>{item.name}</span>

View File

@@ -0,0 +1,81 @@
import type { PopupProps } from '../config-popup'
import { fireEvent, render, screen } from '@testing-library/react'
import ConfigButton from '../config-button'
import { TracingProvider } from '../type'
vi.mock('../config-popup', () => ({
default: ({ chosenProvider }: { chosenProvider: TracingProvider | null }) => (
<div>{`config-popup:${chosenProvider ?? 'none'}`}</div>
),
}))
const createPopupProps = (overrides: Partial<PopupProps> = {}): PopupProps => ({
appId: 'app-1',
readOnly: false,
enabled: false,
onStatusChange: vi.fn(),
chosenProvider: TracingProvider.langfuse,
onChooseProvider: vi.fn(),
arizeConfig: null,
phoenixConfig: null,
langSmithConfig: null,
langFuseConfig: null,
opikConfig: null,
weaveConfig: null,
aliyunConfig: null,
mlflowConfig: null,
databricksConfig: null,
tencentConfig: null,
onConfigUpdated: vi.fn(),
onConfigRemoved: vi.fn(),
...overrides,
})
describe('OverviewRouteConfigButton', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return null when the button is read-only without any existing config', () => {
const { container } = render(
<ConfigButton
{...createPopupProps({ readOnly: true })}
hasConfigured={false}
>
<span>open-config</span>
</ConfigButton>,
)
expect(container).toBeEmptyDOMElement()
})
it('should render and toggle the popup when clicked', () => {
render(
<ConfigButton
{...createPopupProps()}
hasConfigured={true}
>
<span>open-config</span>
</ConfigButton>,
)
fireEvent.click(screen.getByText('open-config'))
expect(screen.getByText('config-popup:langfuse')).toBeInTheDocument()
fireEvent.click(screen.getByText('open-config'))
expect(screen.queryByText('config-popup:langfuse')).not.toBeInTheDocument()
})
it('should still render configured content in read-only mode', () => {
render(
<ConfigButton
{...createPopupProps({ readOnly: true, chosenProvider: TracingProvider.opik })}
hasConfigured={true}
>
<span>configured-button</span>
</ConfigButton>,
)
expect(screen.getByText('configured-button')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,231 @@
import type { PopupProps } from '../config-popup'
import { fireEvent, render, screen } from '@testing-library/react'
import ConfigPopup from '../config-popup'
import { TracingProvider } from '../type'
vi.mock('@/app/components/base/divider', () => ({
default: () => <div data-testid="divider" />,
}))
vi.mock('@/app/components/base/switch', () => ({
default: ({
value,
onChange,
disabled,
}: {
value: boolean
onChange: (value: boolean) => void
disabled?: boolean
}) => (
<button
type="button"
disabled={disabled}
onClick={() => !disabled && onChange(!value)}
>
{`switch:${value ? 'on' : 'off'}:${disabled ? 'disabled' : 'enabled'}`}
</button>
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({
popupContent,
children,
}: {
popupContent: React.ReactNode
children: React.ReactNode
}) => (
<div>
<div>{popupContent}</div>
{children}
</div>
),
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <div>{`indicator:${color}`}</div>,
}))
vi.mock('../tracing-icon', () => ({
default: ({ size }: { size: string }) => <div>{`tracing-icon:${size}`}</div>,
}))
vi.mock('../provider-panel', () => ({
default: ({
type,
hasConfigured,
onConfig,
onChoose,
}: {
type: TracingProvider
hasConfigured: boolean
onConfig: () => void
onChoose: () => void
}) => (
<div>
<div>{`provider-panel:${type}:${hasConfigured ? 'configured' : 'empty'}`}</div>
<button type="button" onClick={onConfig}>{`config:${type}`}</button>
<button type="button" onClick={onChoose}>{`choose:${type}`}</button>
</div>
),
}))
vi.mock('../provider-config-modal', () => ({
default: ({
type,
onSaved,
onRemoved,
onCancel,
}: {
type: TracingProvider
onSaved: (payload: Record<string, string>) => void
onRemoved: () => void
onCancel: () => void
}) => (
<div>
<div>{`provider-config-modal:${type}`}</div>
<button type="button" onClick={() => onSaved({ saved: type })}>save-config-modal</button>
<button type="button" onClick={onRemoved}>remove-config-modal</button>
<button type="button" onClick={onCancel}>cancel-config-modal</button>
</div>
),
}))
const createProps = (overrides: Partial<PopupProps> = {}): PopupProps => ({
appId: 'app-1',
readOnly: false,
enabled: false,
onStatusChange: vi.fn(),
chosenProvider: null,
onChooseProvider: vi.fn(),
arizeConfig: null,
phoenixConfig: null,
langSmithConfig: null,
langFuseConfig: null,
opikConfig: null,
weaveConfig: null,
aliyunConfig: null,
mlflowConfig: null,
databricksConfig: null,
tencentConfig: null,
onConfigUpdated: vi.fn(),
onConfigRemoved: vi.fn(),
...overrides,
})
describe('OverviewRouteConfigPopup', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render all providers in the not-configured state and disable the switch', () => {
render(<ConfigPopup {...createProps()} />)
expect(screen.getByText('app.tracing.configProviderTitle.notConfigured')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'switch:off:disabled' })).toBeDisabled()
expect(screen.getByText('provider-panel:langfuse:empty')).toBeInTheDocument()
expect(screen.getByText('provider-panel:tencent:empty')).toBeInTheDocument()
})
it('should render configured and more-provider sections when configs are mixed', () => {
const onChooseProvider = vi.fn()
const onConfigUpdated = vi.fn()
const onConfigRemoved = vi.fn()
render(
<ConfigPopup
{...createProps({
enabled: true,
chosenProvider: TracingProvider.langfuse,
onChooseProvider,
onConfigUpdated,
onConfigRemoved,
langFuseConfig: { secret_key: 'secret', public_key: 'public', host: 'https://langfuse.example' },
opikConfig: { api_key: 'opik-key', project: 'opik-project', workspace: 'default', url: 'https://opik.example' },
})}
/>,
)
expect(screen.getByText('app.tracing.configProviderTitle.configured')).toBeInTheDocument()
expect(screen.getByText('app.tracing.configProviderTitle.moreProvider')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'switch:on:enabled' }))
fireEvent.click(screen.getByRole('button', { name: 'choose:opik' }))
fireEvent.click(screen.getByRole('button', { name: 'config:langfuse' }))
expect(onChooseProvider).toHaveBeenCalledWith(TracingProvider.opik)
expect(screen.getByText('provider-config-modal:langfuse')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'save-config-modal' }))
expect(onConfigUpdated).toHaveBeenCalledWith(TracingProvider.langfuse, { saved: TracingProvider.langfuse })
fireEvent.click(screen.getByRole('button', { name: 'config:langfuse' }))
fireEvent.click(screen.getByRole('button', { name: 'remove-config-modal' }))
expect(onConfigRemoved).toHaveBeenCalledWith(TracingProvider.langfuse)
})
it('should render the configured-only section when every provider already has a config', () => {
const configured = { api_key: 'value', project: 'project', endpoint: '' }
render(
<ConfigPopup
{...createProps({
enabled: true,
arizeConfig: { api_key: 'k', space_id: 's', project: 'p', endpoint: '' },
phoenixConfig: configured,
langSmithConfig: configured,
langFuseConfig: { secret_key: 's', public_key: 'p', host: 'https://langfuse.example' },
opikConfig: { api_key: 'k', project: 'p', workspace: '', url: '' },
weaveConfig: { api_key: 'k', entity: '', project: 'p', endpoint: '', host: '' },
aliyunConfig: { app_name: 'app', license_key: 'license', endpoint: '' },
mlflowConfig: { tracking_uri: 'uri', experiment_id: 'exp', username: '', password: '' },
databricksConfig: { experiment_id: 'exp', host: 'host', client_id: '', client_secret: '', personal_access_token: '' },
tencentConfig: { token: 'token', endpoint: 'endpoint', service_name: 'service' },
})}
/>,
)
expect(screen.getByText('app.tracing.configProviderTitle.configured')).toBeInTheDocument()
expect(screen.queryByText('app.tracing.configProviderTitle.moreProvider')).not.toBeInTheDocument()
expect(screen.getByText('provider-panel:langfuse:configured')).toBeInTheDocument()
expect(screen.getByText('provider-panel:tencent:configured')).toBeInTheDocument()
})
it('should resolve provider payloads for every config button in mixed mode', () => {
render(
<ConfigPopup
{...createProps({
arizeConfig: { api_key: 'k', space_id: 's', project: 'p', endpoint: '' },
phoenixConfig: { api_key: 'k', project: 'p', endpoint: '' },
langSmithConfig: null,
langFuseConfig: null,
opikConfig: null,
weaveConfig: { api_key: 'k', entity: '', project: 'p', endpoint: '', host: '' },
aliyunConfig: { app_name: 'app', license_key: 'license', endpoint: '' },
mlflowConfig: { tracking_uri: 'uri', experiment_id: 'exp', username: '', password: '' },
databricksConfig: { experiment_id: 'exp', host: 'host', client_id: '', client_secret: '', personal_access_token: '' },
tencentConfig: { token: 'token', endpoint: 'endpoint', service_name: 'service' },
})}
/>,
)
const providers = [
TracingProvider.arize,
TracingProvider.phoenix,
TracingProvider.langSmith,
TracingProvider.langfuse,
TracingProvider.opik,
TracingProvider.weave,
TracingProvider.aliyun,
TracingProvider.mlflow,
TracingProvider.databricks,
TracingProvider.tencent,
]
providers.forEach((provider) => {
fireEvent.click(screen.getByRole('button', { name: `config:${provider}` }))
expect(screen.getByText(`provider-config-modal:${provider}`)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'cancel-config-modal' }))
})
})
})

View File

@@ -0,0 +1,40 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Field from '../field'
describe('OverviewRouteTracingField', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render label, placeholder, and required marker', () => {
render(
<Field
label="API Key"
value=""
placeholder="Enter token"
isRequired={true}
onChange={vi.fn()}
/>,
)
expect(screen.getByText('API Key')).toBeInTheDocument()
expect(screen.getByText('*')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Enter token')).toBeInTheDocument()
})
it('should forward input changes as plain string values', () => {
const onChange = vi.fn()
render(
<Field
label="Endpoint"
value=""
onChange={onChange}
/>,
)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'https://example.com' } })
expect(onChange).toHaveBeenCalledWith('https://example.com')
})
})

View File

@@ -0,0 +1,239 @@
import type { TracingStatus } from '@/models/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import Panel from '../panel'
import { TracingProvider } from '../type'
const mockFetchTracingStatus = vi.fn()
const mockFetchTracingConfig = vi.fn()
const mockUpdateTracingStatus = vi.fn()
const mockToast = vi.fn()
const panelState = vi.hoisted(() => ({
pathname: '/app/test-app/overview',
isCurrentWorkspaceEditor: true,
}))
vi.mock('@/app/components/base/loading', () => ({
default: () => <div>loading</div>,
}))
vi.mock('@/app/components/base/divider', () => ({
default: () => <div data-testid="divider" />,
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: (...args: unknown[]) => mockToast(...args),
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <div>{`indicator:${color}`}</div>,
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({ isCurrentWorkspaceEditor: panelState.isCurrentWorkspaceEditor }),
}))
vi.mock('@/next/navigation', () => ({
usePathname: () => panelState.pathname,
}))
vi.mock('@/service/apps', () => ({
fetchTracingStatus: (...args: unknown[]) => mockFetchTracingStatus(...args),
fetchTracingConfig: (...args: unknown[]) => mockFetchTracingConfig(...args),
updateTracingStatus: (...args: unknown[]) => mockUpdateTracingStatus(...args),
}))
vi.mock('../tracing-icon', () => ({
default: ({ size }: { size: string }) => <div>{`tracing-icon:${size}`}</div>,
}))
vi.mock('../config-button', () => ({
default: ({
hasConfigured,
enabled,
chosenProvider,
readOnly,
onStatusChange,
onChooseProvider,
onConfigUpdated,
onConfigRemoved,
children,
}: {
hasConfigured: boolean
enabled: boolean
chosenProvider: TracingProvider | null
readOnly: boolean
onStatusChange: (enabled: boolean) => void
onChooseProvider: (provider: TracingProvider) => void
onConfigUpdated: (provider: TracingProvider) => void
onConfigRemoved: (provider: TracingProvider) => void
children: React.ReactNode
}) => (
<div>
<div>{`config-button:${hasConfigured ? 'configured' : 'primary'}:${readOnly ? 'readonly' : 'editable'}`}</div>
<div>{children}</div>
<button type="button" onClick={() => onStatusChange(!enabled)}>{`toggle:${hasConfigured ? 'configured' : 'primary'}`}</button>
<button type="button" onClick={() => onChooseProvider(TracingProvider.langfuse)}>{`choose:${hasConfigured ? 'configured' : 'primary'}`}</button>
{[TracingProvider.arize, TracingProvider.phoenix, TracingProvider.langSmith, TracingProvider.langfuse, TracingProvider.opik, TracingProvider.weave, TracingProvider.aliyun, TracingProvider.tencent].map(provider => (
<button key={`update-${provider}`} type="button" onClick={() => onConfigUpdated(provider)}>{`updated:${hasConfigured ? 'configured' : 'primary'}:${provider}`}</button>
))}
{[TracingProvider.arize, TracingProvider.phoenix, TracingProvider.langSmith, TracingProvider.langfuse, TracingProvider.opik, TracingProvider.weave, TracingProvider.aliyun, TracingProvider.mlflow, TracingProvider.databricks, TracingProvider.tencent].map(provider => (
<button key={`remove-${provider}`} type="button" onClick={() => onConfigRemoved(chosenProvider ?? provider)}>{`removed:${hasConfigured ? 'configured' : 'primary'}:${provider}`}</button>
))}
</div>
),
}))
const createTracingStatus = (overrides: Partial<TracingStatus> = {}): TracingStatus => ({
tracing_provider: null,
enabled: false,
...overrides,
})
const createFetchConfigResponse = (provider: TracingProvider) => {
if (provider === TracingProvider.langfuse) {
return {
tracing_config: { secret_key: 'secret', public_key: 'public', host: 'https://langfuse.example' },
has_not_configured: false,
}
}
return {
tracing_config: null,
has_not_configured: true,
}
}
describe('OverviewRouteTracingPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
panelState.pathname = '/app/test-app/overview'
panelState.isCurrentWorkspaceEditor = true
mockUpdateTracingStatus.mockResolvedValue(undefined)
mockFetchTracingStatus.mockResolvedValue(createTracingStatus())
mockFetchTracingConfig.mockImplementation(({ provider }: { provider: TracingProvider }) => Promise.resolve(createFetchConfigResponse(provider)))
})
it('should show the loading state while tracing status is still pending', () => {
mockFetchTracingStatus.mockReturnValue(new Promise(() => {}))
render(<Panel />)
expect(screen.getByText('loading')).toBeInTheDocument()
})
it('should load tracing data and handle status, choose, and refresh actions', async () => {
render(<Panel />)
await waitFor(() => expect(screen.getByText('config-button:primary:editable')).toBeInTheDocument())
await waitFor(() => expect(screen.getByText('config-button:configured:editable')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'toggle:configured' }))
fireEvent.click(screen.getByRole('button', { name: 'choose:primary' }))
fireEvent.click(screen.getByRole('button', { name: 'updated:configured:langfuse' }))
await waitFor(() => {
expect(mockUpdateTracingStatus).toHaveBeenCalledWith({
appId: 'test-app',
body: {
tracing_provider: null,
enabled: true,
},
})
})
expect(mockUpdateTracingStatus).toHaveBeenCalledWith({
appId: 'test-app',
body: {
tracing_provider: TracingProvider.langfuse,
enabled: true,
},
})
expect(mockFetchTracingConfig).toHaveBeenCalledWith({
appId: 'test-app',
provider: TracingProvider.langfuse,
})
expect(mockToast).toHaveBeenCalledWith('common.api.success', { type: 'success' })
})
it('should disable the current tracing provider without a toast when that provider is removed', async () => {
mockFetchTracingStatus.mockResolvedValue(createTracingStatus({
enabled: true,
tracing_provider: TracingProvider.langfuse,
}))
render(<Panel />)
await waitFor(() => expect(screen.getByText('config-button:configured:editable')).toBeInTheDocument())
expect(screen.queryByText('config-button:primary:editable')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'removed:configured:langfuse' }))
await waitFor(() => {
expect(mockUpdateTracingStatus).toHaveBeenCalledWith({
appId: 'test-app',
body: {
enabled: false,
tracing_provider: null,
},
})
})
})
it('should pass read-only state through to config buttons for viewers', async () => {
panelState.isCurrentWorkspaceEditor = false
render(<Panel />)
await waitFor(() => expect(screen.getByText('config-button:primary:readonly')).toBeInTheDocument())
})
it('should hydrate and update every provider branch when all tracing configs are available', async () => {
mockFetchTracingConfig.mockImplementation(({ provider }: { provider: TracingProvider }) => Promise.resolve({
tracing_config: { provider },
has_not_configured: false,
}))
render(<Panel />)
await waitFor(() => expect(screen.getByText('config-button:configured:editable')).toBeInTheDocument())
const updateProviders = [
TracingProvider.arize,
TracingProvider.phoenix,
TracingProvider.langSmith,
TracingProvider.langfuse,
TracingProvider.opik,
TracingProvider.weave,
TracingProvider.aliyun,
TracingProvider.tencent,
]
updateProviders.forEach((provider) => {
fireEvent.click(screen.getByRole('button', { name: `updated:configured:${provider}` }))
})
const removeProviders = [
TracingProvider.arize,
TracingProvider.phoenix,
TracingProvider.langSmith,
TracingProvider.opik,
TracingProvider.weave,
TracingProvider.aliyun,
TracingProvider.mlflow,
TracingProvider.databricks,
TracingProvider.tencent,
]
removeProviders.forEach((provider) => {
fireEvent.click(screen.getByRole('button', { name: `removed:configured:${provider}` }))
})
await waitFor(() => {
expect(mockFetchTracingConfig).toHaveBeenCalledWith({
appId: 'test-app',
provider: TracingProvider.arize,
})
})
})
})

View File

@@ -0,0 +1,366 @@
import type { LangSmithConfig, WeaveConfig } from '../type'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import ProviderConfigModal from '../provider-config-modal'
import { TracingProvider } from '../type'
const mockToast = vi.fn()
const mockAddTracingConfig = vi.fn()
const mockUpdateTracingConfig = vi.fn()
const mockRemoveTracingConfig = vi.fn()
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<button type="button" onClick={onClick}>{children}</button>
),
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({
title,
content,
onConfirm,
onCancel,
}: {
title: string
content: string
onConfirm: () => void
onCancel: () => void
}) => (
<div>
<div>{title}</div>
<div>{content}</div>
<button type="button" onClick={onConfirm}>confirm-remove</button>
<button type="button" onClick={onCancel}>cancel-remove</button>
</div>
),
}))
vi.mock('@/app/components/base/divider', () => ({
default: () => <div data-testid="divider" />,
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({
LinkExternal02: () => <span>external-link-icon</span>,
}))
vi.mock('@/app/components/base/icons/src/vender/solid/security', () => ({
Lock01: () => <span>lock-icon</span>,
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: (...args: unknown[]) => mockToast(...args),
}))
vi.mock('@/service/apps', () => ({
addTracingConfig: (...args: unknown[]) => mockAddTracingConfig(...args),
updateTracingConfig: (...args: unknown[]) => mockUpdateTracingConfig(...args),
removeTracingConfig: (...args: unknown[]) => mockRemoveTracingConfig(...args),
}))
vi.mock('../field', () => ({
default: ({
label,
value,
onChange,
}: {
label: string
value: string
onChange: (value: string) => void
}) => (
<label>
{label}
<input
aria-label={label}
value={value}
onChange={event => onChange(event.target.value)}
/>
</label>
),
}))
type SaveCase = {
provider: TracingProvider
fields: Array<{ label: string, value: string }>
expected: Record<string, string>
}
const saveCases: SaveCase[] = [
{
provider: TracingProvider.arize,
fields: [
{ label: 'API Key', value: 'arize-key' },
{ label: 'Space ID', value: 'space-1' },
{ label: 'app.tracing.configProvider.project', value: 'arize-project' },
{ label: 'Endpoint', value: 'https://arize.example' },
],
expected: { api_key: 'arize-key', space_id: 'space-1', project: 'arize-project', endpoint: 'https://arize.example' },
},
{
provider: TracingProvider.phoenix,
fields: [
{ label: 'API Key', value: 'phoenix-key' },
{ label: 'app.tracing.configProvider.project', value: 'phoenix-project' },
{ label: 'Endpoint', value: 'https://phoenix.example' },
],
expected: { api_key: 'phoenix-key', project: 'phoenix-project', endpoint: 'https://phoenix.example' },
},
{
provider: TracingProvider.langSmith,
fields: [
{ label: 'API Key', value: 'smith-key' },
{ label: 'app.tracing.configProvider.project', value: 'smith-project' },
{ label: 'Endpoint', value: 'https://smith.example' },
],
expected: { api_key: 'smith-key', project: 'smith-project', endpoint: 'https://smith.example' },
},
{
provider: TracingProvider.langfuse,
fields: [
{ label: 'app.tracing.configProvider.secretKey', value: 'secret' },
{ label: 'app.tracing.configProvider.publicKey', value: 'public' },
{ label: 'Host', value: 'https://langfuse.example' },
],
expected: { secret_key: 'secret', public_key: 'public', host: 'https://langfuse.example' },
},
{
provider: TracingProvider.opik,
fields: [
{ label: 'API Key', value: 'opik-key' },
{ label: 'app.tracing.configProvider.project', value: 'opik-project' },
{ label: 'Workspace', value: 'workspace-1' },
{ label: 'Url', value: 'https://opik.example' },
],
expected: { api_key: 'opik-key', project: 'opik-project', workspace: 'workspace-1', url: 'https://opik.example' },
},
{
provider: TracingProvider.weave,
fields: [
{ label: 'API Key', value: 'weave-key' },
{ label: 'app.tracing.configProvider.project', value: 'weave-project' },
{ label: 'Entity', value: 'entity-1' },
{ label: 'Endpoint', value: 'https://weave-endpoint.example' },
{ label: 'Host', value: 'https://weave-host.example' },
],
expected: { api_key: 'weave-key', project: 'weave-project', entity: 'entity-1', endpoint: 'https://weave-endpoint.example', host: 'https://weave-host.example' },
},
{
provider: TracingProvider.aliyun,
fields: [
{ label: 'License Key', value: 'license-1' },
{ label: 'Endpoint', value: 'https://aliyun.example' },
{ label: 'App Name', value: 'aliyun-app' },
],
expected: { license_key: 'license-1', endpoint: 'https://aliyun.example', app_name: 'aliyun-app' },
},
{
provider: TracingProvider.mlflow,
fields: [
{ label: 'app.tracing.configProvider.trackingUri', value: 'http://mlflow.local' },
{ label: 'app.tracing.configProvider.experimentId', value: 'exp-1' },
{ label: 'app.tracing.configProvider.username', value: 'ml-user' },
{ label: 'app.tracing.configProvider.password', value: 'ml-pass' },
],
expected: { tracking_uri: 'http://mlflow.local', experiment_id: 'exp-1', username: 'ml-user', password: 'ml-pass' },
},
{
provider: TracingProvider.databricks,
fields: [
{ label: 'app.tracing.configProvider.experimentId', value: 'db-exp' },
{ label: 'app.tracing.configProvider.databricksHost', value: 'https://databricks.example' },
{ label: 'app.tracing.configProvider.clientId', value: 'client-id' },
{ label: 'app.tracing.configProvider.clientSecret', value: 'client-secret' },
{ label: 'app.tracing.configProvider.personalAccessToken', value: 'token-1' },
],
expected: { experiment_id: 'db-exp', host: 'https://databricks.example', client_id: 'client-id', client_secret: 'client-secret', personal_access_token: 'token-1' },
},
{
provider: TracingProvider.tencent,
fields: [
{ label: 'Token', value: 'token-1' },
{ label: 'Endpoint', value: 'https://tencent.example' },
{ label: 'Service Name', value: 'svc-1' },
],
expected: { token: 'token-1', endpoint: 'https://tencent.example', service_name: 'svc-1' },
},
]
const fillFields = (fields: SaveCase['fields']) => {
fields.forEach(({ label, value }) => {
fireEvent.change(screen.getByLabelText(label), { target: { value } })
})
}
type InvalidCase = {
provider: TracingProvider
fields?: Array<{ label: string, value: string }>
}
const invalidCases: InvalidCase[] = [
{ provider: TracingProvider.arize },
{ provider: TracingProvider.arize, fields: [{ label: 'API Key', value: 'api-key' }] },
{ provider: TracingProvider.arize, fields: [{ label: 'API Key', value: 'api-key' }, { label: 'Space ID', value: 'space-id' }] },
{ provider: TracingProvider.phoenix },
{ provider: TracingProvider.phoenix, fields: [{ label: 'API Key', value: 'api-key' }] },
{ provider: TracingProvider.langSmith },
{ provider: TracingProvider.langSmith, fields: [{ label: 'API Key', value: 'api-key' }] },
{ provider: TracingProvider.langfuse, fields: [{ label: 'app.tracing.configProvider.secretKey', value: 'secret' }] },
{ provider: TracingProvider.langfuse, fields: [{ label: 'app.tracing.configProvider.secretKey', value: 'secret' }, { label: 'app.tracing.configProvider.publicKey', value: 'public' }] },
{ provider: TracingProvider.weave },
{ provider: TracingProvider.weave, fields: [{ label: 'API Key', value: 'api-key' }] },
{ provider: TracingProvider.aliyun },
{ provider: TracingProvider.aliyun, fields: [{ label: 'App Name', value: 'aliyun-app' }] },
{ provider: TracingProvider.aliyun, fields: [{ label: 'App Name', value: 'aliyun-app' }, { label: 'License Key', value: 'license' }] },
{ provider: TracingProvider.mlflow },
{ provider: TracingProvider.databricks },
{ provider: TracingProvider.databricks, fields: [{ label: 'app.tracing.configProvider.experimentId', value: 'exp-id' }] },
{ provider: TracingProvider.tencent },
{ provider: TracingProvider.tencent, fields: [{ label: 'Token', value: 'token' }] },
{ provider: TracingProvider.tencent, fields: [{ label: 'Token', value: 'token' }, { label: 'Endpoint', value: 'https://tencent.example' }] },
]
describe('OverviewRouteProviderConfigModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAddTracingConfig.mockResolvedValue(undefined)
mockUpdateTracingConfig.mockResolvedValue(undefined)
mockRemoveTracingConfig.mockResolvedValue(undefined)
})
it.each(saveCases)('should render and save %s provider configs in add mode', async ({ provider, fields, expected }) => {
const onSaved = vi.fn()
const onChosen = vi.fn()
render(
<ProviderConfigModal
appId="app-1"
type={provider}
onRemoved={vi.fn()}
onCancel={vi.fn()}
onSaved={onSaved}
onChosen={onChosen}
/>,
)
fillFields(fields)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.saveAndEnable' }))
await waitFor(() => {
expect(mockAddTracingConfig).toHaveBeenCalledWith({
appId: 'app-1',
body: {
tracing_provider: provider,
tracing_config: expect.objectContaining(expected),
},
})
})
expect(onSaved).toHaveBeenCalledWith(expect.objectContaining(expected))
expect(onChosen).toHaveBeenCalledWith(provider)
expect(mockToast).toHaveBeenCalledWith('common.api.success', { type: 'success' })
})
it.each(invalidCases)('should surface validation errors for invalid %s configs', ({ provider, fields }) => {
render(
<ProviderConfigModal
appId="app-1"
type={provider}
onRemoved={vi.fn()}
onCancel={vi.fn()}
onSaved={vi.fn()}
onChosen={vi.fn()}
/>,
)
fields?.forEach(({ label, value }) => {
fireEvent.change(screen.getByLabelText(label), { target: { value } })
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.saveAndEnable' }))
expect(mockAddTracingConfig).not.toHaveBeenCalled()
expect(mockToast).toHaveBeenCalledWith(expect.stringContaining('common.errorMsg.fieldRequired'), { type: 'error' })
})
it('should update existing configs without re-choosing the provider', async () => {
const payload: LangSmithConfig = {
api_key: 'existing-key',
project: 'existing-project',
endpoint: 'https://smith.example',
}
const onChosen = vi.fn()
const onSaved = vi.fn()
render(
<ProviderConfigModal
appId="app-1"
type={TracingProvider.langSmith}
payload={payload}
onRemoved={vi.fn()}
onCancel={vi.fn()}
onSaved={onSaved}
onChosen={onChosen}
/>,
)
fireEvent.change(screen.getByLabelText('Endpoint'), { target: { value: 'https://updated.example' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockUpdateTracingConfig).toHaveBeenCalledWith({
appId: 'app-1',
body: {
tracing_provider: TracingProvider.langSmith,
tracing_config: expect.objectContaining({
api_key: 'existing-key',
project: 'existing-project',
endpoint: 'https://updated.example',
}),
},
})
})
expect(onSaved).toHaveBeenCalled()
expect(onChosen).not.toHaveBeenCalled()
})
it('should confirm and remove an existing config', async () => {
const payload: WeaveConfig = {
api_key: 'weave-key',
project: 'weave-project',
entity: '',
endpoint: '',
host: '',
}
const onRemoved = vi.fn()
render(
<ProviderConfigModal
appId="app-1"
type={TracingProvider.weave}
payload={payload}
onRemoved={onRemoved}
onCancel={vi.fn()}
onSaved={vi.fn()}
onChosen={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' }))
fireEvent.click(screen.getByRole('button', { name: 'confirm-remove' }))
await waitFor(() => {
expect(mockRemoveTracingConfig).toHaveBeenCalledWith({
appId: 'app-1',
provider: TracingProvider.weave,
})
})
expect(onRemoved).toHaveBeenCalledTimes(1)
expect(mockToast).toHaveBeenCalledWith('common.api.remove', { type: 'success' })
})
})

View File

@@ -0,0 +1,122 @@
import { fireEvent, render, screen } from '@testing-library/react'
import ProviderPanel from '../provider-panel'
import { TracingProvider } from '../type'
describe('OverviewRouteProviderPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(window, 'open').mockImplementation(() => null)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should render provider description and open the config action', () => {
const onConfig = vi.fn()
render(
<ProviderPanel
type={TracingProvider.langfuse}
readOnly={false}
isChosen={false}
config={null}
hasConfigured={false}
onChoose={vi.fn()}
onConfig={onConfig}
/>,
)
expect(screen.getByText('app.tracing.langfuse.description')).toBeInTheDocument()
fireEvent.click(screen.getByText('app.tracing.config'))
expect(onConfig).toHaveBeenCalledTimes(1)
})
it('should open the provider project page when view is clicked', () => {
render(
<ProviderPanel
type={TracingProvider.langSmith}
readOnly={false}
isChosen={false}
config={{ project_url: 'https://example.com/project' }}
hasConfigured={true}
onChoose={vi.fn()}
onConfig={vi.fn()}
/>,
)
fireEvent.click(screen.getByText('app.tracing.view'))
expect(window.open).toHaveBeenCalledWith('https://example.com/project', '_blank', 'noopener,noreferrer')
})
it('should choose a configured provider when it is clickable', () => {
const onChoose = vi.fn()
render(
<ProviderPanel
type={TracingProvider.opik}
readOnly={false}
isChosen={false}
config={{}}
hasConfigured={true}
onChoose={onChoose}
onConfig={vi.fn()}
/>,
)
fireEvent.click(screen.getByText('app.tracing.opik.description'))
expect(onChoose).toHaveBeenCalledTimes(1)
})
it('should ignore choose clicks when the provider is read-only, chosen, or not configured', () => {
const onChoose = vi.fn()
const { rerender } = render(
<ProviderPanel
type={TracingProvider.weave}
readOnly={true}
isChosen={false}
config={{}}
hasConfigured={true}
onChoose={onChoose}
onConfig={vi.fn()}
/>,
)
fireEvent.click(screen.getByText('app.tracing.weave.description'))
rerender(
<ProviderPanel
type={TracingProvider.weave}
readOnly={false}
isChosen={true}
config={{}}
hasConfigured={true}
onChoose={onChoose}
onConfig={vi.fn()}
/>,
)
fireEvent.click(screen.getByText('app.tracing.inUse'))
rerender(
<ProviderPanel
type={TracingProvider.weave}
readOnly={false}
isChosen={false}
config={{}}
hasConfigured={false}
onChoose={onChoose}
onConfig={vi.fn()}
/>,
)
fireEvent.click(screen.getByText('app.tracing.weave.description'))
expect(onChoose).not.toHaveBeenCalled()
})
})

View File

@@ -1,5 +1,7 @@
/* eslint-disable hyoban/prefer-tailwind-icons */
import { render } from '@testing-library/react'
import * as React from 'react'
import { OpikIconBig } from '@/app/components/base/icons/src/public/tracing'
import iconData from '@/app/components/base/icons/src/public/tracing/OpikIconBig.json'
import { normalizeAttrs } from '@/app/components/base/icons/utils'

View File

@@ -0,0 +1,17 @@
import { render } from '@testing-library/react'
import TracingIcon from '../tracing-icon'
describe('OverviewRouteTracingIcon', () => {
it('should render the medium icon size classes with custom class names', () => {
const { container } = render(<TracingIcon size="md" className="custom-class" />)
expect(container.firstChild).toHaveClass('custom-class', 'w-6', 'h-6', 'p-1', 'rounded-lg', 'bg-primary-500')
})
it('should render the large icon size classes', () => {
const { container } = render(<TracingIcon size="lg" />)
expect(container.firstChild).toHaveClass('w-9', 'h-9', 'p-2', 'rounded-[10px]')
expect(container.querySelector('svg')).toBeInTheDocument()
})
})

View File

@@ -1,441 +0,0 @@
'use client'
import type { ConfigParams } from './settings'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import {
RiArrowRightSLine,
RiBookOpenLine,
RiBuildingLine,
RiEqualizer2Line,
RiExternalLinkLine,
RiGlobalLine,
RiLockLine,
RiPaintBrushLine,
RiVerifiedBadgeLine,
RiWindowLine,
} from '@remixicon/react'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppBasic from '@/app/components/app-sidebar/basic'
import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import Confirm from '@/app/components/base/confirm'
import CopyFeedback from '@/app/components/base/copy-feedback'
import Divider from '@/app/components/base/divider'
import ShareQRCode from '@/app/components/base/qrcode'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import SecretKeyButton from '@/app/components/develop/secret-key/secret-key-button'
import Indicator from '@/app/components/header/indicator'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useDocLink } from '@/context/i18n'
import { AccessMode } from '@/models/access-control'
import { usePathname, useRouter } from '@/next/navigation'
import { useAppWhiteListSubjects } from '@/service/access-control'
import { fetchAppDetailDirect } from '@/service/apps'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
import { asyncRunSafe } from '@/utils'
import { basePath } from '@/utils/var'
import AccessControl from '../app-access-control'
import CustomizeModal from './customize'
import EmbeddedModal from './embedded'
import SettingsModal from './settings'
import style from './style.module.css'
export type IAppCardProps = {
className?: string
appInfo: AppDetailResponse & Partial<AppSSO>
isInPanel?: boolean
cardType?: 'api' | 'webapp'
customBgColor?: string
triggerModeDisabled?: boolean // true when Trigger Node mode needs UI locked to avoid conflicting actions
triggerModeMessage?: React.ReactNode // contextual copy explaining why the card is disabled in trigger mode
onChangeStatus: (val: boolean) => Promise<void>
onSaveSiteConfig?: (params: ConfigParams) => Promise<void>
onGenerateCode?: () => Promise<void>
}
function AppCard({
appInfo,
isInPanel,
cardType = 'webapp',
customBgColor,
triggerModeDisabled = false,
triggerModeMessage = '',
onChangeStatus,
onSaveSiteConfig,
onGenerateCode,
className,
}: IAppCardProps) {
const router = useRouter()
const pathname = usePathname()
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
const { data: currentWorkflow } = useAppWorkflow(appInfo.mode === AppModeEnum.WORKFLOW ? appInfo.id : '')
const docLink = useDocLink()
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const [showSettingsModal, setShowSettingsModal] = useState(false)
const [showEmbedded, setShowEmbedded] = useState(false)
const [showCustomizeModal, setShowCustomizeModal] = useState(false)
const [genLoading, setGenLoading] = useState(false)
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showAccessControl, setShowAccessControl] = useState<boolean>(false)
const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { data: appAccessSubjects } = useAppWhiteListSubjects(appDetail?.id, systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
const OPERATIONS_MAP = useMemo(() => {
const operationsMap = {
webapp: [
{ opName: t('overview.appInfo.launch', { ns: 'appOverview' }), opIcon: RiExternalLinkLine },
] as { opName: string, opIcon: any }[],
api: [{ opName: t('overview.apiInfo.doc', { ns: 'appOverview' }), opIcon: RiBookOpenLine }],
app: [],
}
if (appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW)
operationsMap.webapp.push({ opName: t('overview.appInfo.embedded.entry', { ns: 'appOverview' }), opIcon: RiWindowLine })
operationsMap.webapp.push({ opName: t('overview.appInfo.customize.entry', { ns: 'appOverview' }), opIcon: RiPaintBrushLine })
if (isCurrentWorkspaceEditor)
operationsMap.webapp.push({ opName: t('overview.appInfo.settings.entry', { ns: 'appOverview' }), opIcon: RiEqualizer2Line })
return operationsMap
}, [isCurrentWorkspaceEditor, appInfo, t])
const isApp = cardType === 'webapp'
const basicName = isApp
? t('overview.appInfo.title', { ns: 'appOverview' })
: t('overview.apiInfo.title', { ns: 'appOverview' })
const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW
const appUnpublished = isWorkflowApp && !currentWorkflow?.graph
const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start)
const missingStartNode = isWorkflowApp && !hasStartNode
const hasInsufficientPermissions = isApp ? !isCurrentWorkspaceEditor : !isCurrentWorkspaceManager
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled
const runningStatus = (appUnpublished || missingStartNode) ? false : (isApp ? appInfo.enable_site : appInfo.enable_api)
const isMinimalState = appUnpublished || missingStartNode
const { app_base_url, access_token } = appInfo.site ?? {}
const appMode = (appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : appInfo.mode
const appUrl = `${app_base_url}${basePath}/${appMode}/${access_token}`
const apiUrl = appInfo?.api_base_url
const genClickFuncByName = (opName: string) => {
switch (opName) {
case t('overview.appInfo.launch', { ns: 'appOverview' }):
return () => {
window.open(appUrl, '_blank')
}
case t('overview.appInfo.customize.entry', { ns: 'appOverview' }):
return () => {
setShowCustomizeModal(true)
}
case t('overview.appInfo.settings.entry', { ns: 'appOverview' }):
return () => {
setShowSettingsModal(true)
}
case t('overview.appInfo.embedded.entry', { ns: 'appOverview' }):
return () => {
setShowEmbedded(true)
}
default:
// jump to page develop
return () => {
const pathSegments = pathname.split('/')
pathSegments.pop()
router.push(`${pathSegments.join('/')}/develop`)
}
}
}
const onGenCode = async () => {
if (onGenerateCode) {
setGenLoading(true)
await asyncRunSafe(onGenerateCode())
setGenLoading(false)
}
}
const [isAppAccessSet, setIsAppAccessSet] = useState(true)
useEffect(() => {
if (appDetail && appAccessSubjects) {
if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
setIsAppAccessSet(false)
else
setIsAppAccessSet(true)
}
else {
setIsAppAccessSet(true)
}
}, [appAccessSubjects, appDetail])
const handleClickAccessControl = useCallback(() => {
if (!appDetail)
return
setShowAccessControl(true)
}, [appDetail])
const handleAccessControlUpdate = useCallback(async () => {
try {
const res = await fetchAppDetailDirect({ url: '/apps', id: appDetail!.id })
setAppDetail(res)
setShowAccessControl(false)
}
catch (error) {
console.error('Failed to fetch app detail:', error)
}
}, [appDetail, setAppDetail])
return (
<div
className={
`${isInPanel ? 'border-l-[0.5px] border-t' : 'border-[0.5px] shadow-xs'} w-full max-w-full rounded-xl border-effects-highlight ${className ?? ''} ${isMinimalState ? 'h-12' : ''}`
}
>
<div className={`${customBgColor ?? 'bg-background-default'} relative rounded-xl ${triggerModeDisabled ? 'opacity-60' : ''}`}>
{triggerModeDisabled && (
triggerModeMessage
? (
<Tooltip
popupContent={triggerModeMessage}
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
position="right"
>
<div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
</Tooltip>
)
: <div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
)}
<div className={`flex w-full flex-col items-start justify-center gap-3 self-stretch p-3 ${isMinimalState ? 'border-0' : 'border-b-[0.5px] border-divider-subtle'}`}>
<div className="flex w-full items-center gap-3 self-stretch">
<AppBasic
iconType={cardType}
icon={appInfo.icon}
icon_background={appInfo.icon_background}
name={basicName}
hideType
type={
isApp
? t('overview.appInfo.explanation', { ns: 'appOverview' })
: t('overview.apiInfo.explanation', { ns: 'appOverview' })
}
/>
<div className="flex shrink-0 items-center gap-1">
<Indicator color={runningStatus ? 'green' : 'yellow'} />
<div className={`${runningStatus ? 'text-text-success' : 'text-text-warning'} system-xs-semibold-uppercase`}>
{runningStatus
? t('overview.status.running', { ns: 'appOverview' })
: t('overview.status.disable', { ns: 'appOverview' })}
</div>
</div>
<Tooltip
popupContent={
toggleDisabled
? (
triggerModeDisabled && triggerModeMessage
? triggerModeMessage
: (appUnpublished || missingStartNode)
? (
<>
<div className="mb-1 text-xs font-normal text-text-secondary">
{t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
</div>
<div
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')}
>
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
</div>
</>
)
: ''
)
: ''
}
position="right"
popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg"
offset={24}
>
<div>
<Switch value={runningStatus} onChange={onChangeStatus} disabled={toggleDisabled} />
</div>
</Tooltip>
</div>
{!isMinimalState && (
<div className="flex flex-col items-start justify-center self-stretch">
<div className="pb-1 text-text-tertiary system-xs-medium">
{isApp
? t('overview.appInfo.accessibleAddress', { ns: 'appOverview' })
: t('overview.apiInfo.accessibleAddress', { ns: 'appOverview' })}
</div>
<div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2">
<div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1">
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-secondary">
{isApp ? appUrl : apiUrl}
</div>
</div>
<CopyFeedback
content={isApp ? appUrl : apiUrl}
className="!size-6"
/>
{isApp && <ShareQRCode content={isApp ? appUrl : apiUrl} />}
{isApp && <Divider type="vertical" className="!mx-0.5 !h-3.5 shrink-0" />}
{/* button copy link/ button regenerate */}
{showConfirmDelete && (
<Confirm
type="warning"
title={t('overview.appInfo.regenerate', { ns: 'appOverview' })}
content={t('overview.appInfo.regenerateNotice', { ns: 'appOverview' })}
isShow={showConfirmDelete}
onConfirm={() => {
onGenCode()
setShowConfirmDelete(false)
}}
onCancel={() => setShowConfirmDelete(false)}
/>
)}
{isApp && isCurrentWorkspaceManager && (
<Tooltip
popupContent={t('overview.appInfo.regenerate', { ns: 'appOverview' }) || ''}
>
<div
className="h-6 w-6 cursor-pointer rounded-md hover:bg-state-base-hover"
onClick={() => setShowConfirmDelete(true)}
>
<div
className={
`h-full w-full ${style.refreshIcon} ${genLoading ? style.generateLogo : ''}`
}
>
</div>
</div>
</Tooltip>
)}
</div>
</div>
)}
{!isMinimalState && isApp && systemFeatures.webapp_auth.enabled && appDetail && (
<div className="flex flex-col items-start justify-center self-stretch">
<div className="pb-1 text-text-tertiary system-xs-medium">{t('publishApp.title', { ns: 'app' })}</div>
<div
className="flex h-9 w-full cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2"
onClick={handleClickAccessControl}
>
<div className="flex grow items-center gap-x-1.5 pr-1">
{appDetail?.access_mode === AccessMode.ORGANIZATION
&& (
<>
<RiBuildingLine className="h-4 w-4 shrink-0 text-text-secondary" />
<p className="text-text-secondary system-sm-medium">{t('accessControlDialog.accessItems.organization', { ns: 'app' })}</p>
</>
)}
{appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS
&& (
<>
<RiLockLine className="h-4 w-4 shrink-0 text-text-secondary" />
<p className="text-text-secondary system-sm-medium">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
</>
)}
{appDetail?.access_mode === AccessMode.PUBLIC
&& (
<>
<RiGlobalLine className="h-4 w-4 shrink-0 text-text-secondary" />
<p className="text-text-secondary system-sm-medium">{t('accessControlDialog.accessItems.anyone', { ns: 'app' })}</p>
</>
)}
{appDetail?.access_mode === AccessMode.EXTERNAL_MEMBERS
&& (
<>
<RiVerifiedBadgeLine className="h-4 w-4 shrink-0 text-text-secondary" />
<p className="text-text-secondary system-sm-medium">{t('accessControlDialog.accessItems.external', { ns: 'app' })}</p>
</>
)}
</div>
{!isAppAccessSet && <p className="shrink-0 text-text-tertiary system-xs-regular">{t('publishApp.notSet', { ns: 'app' })}</p>}
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<RiArrowRightSLine className="h-4 w-4 text-text-quaternary" />
</div>
</div>
</div>
)}
</div>
{!isMinimalState && (
<div className="flex items-center gap-1 self-stretch p-3">
{!isApp && <SecretKeyButton appId={appInfo.id} />}
{OPERATIONS_MAP[cardType].map((op) => {
const disabled
= triggerModeDisabled
? true
: op.opName === t('overview.appInfo.settings.entry', { ns: 'appOverview' })
? false
: !runningStatus
return (
<Button
className="mr-1 min-w-[88px]"
size="small"
variant="ghost"
key={op.opName}
onClick={genClickFuncByName(op.opName)}
disabled={disabled}
>
<Tooltip
popupContent={
t('overview.appInfo.preUseReminder', { ns: 'appOverview' }) ?? ''
}
popupClassName={disabled ? 'mt-[-8px]' : '!hidden'}
>
<div className="flex items-center justify-center gap-[1px]">
<op.opIcon className="h-3.5 w-3.5" />
<div className={`${(runningStatus || !disabled) ? 'text-text-tertiary' : 'text-components-button-ghost-text-disabled'} px-[3px] system-xs-medium`}>{op.opName}</div>
</div>
</Tooltip>
</Button>
)
})}
</div>
)}
</div>
{isApp
? (
<>
<SettingsModal
isChat={appMode === AppModeEnum.CHAT}
appInfo={appInfo}
isShow={showSettingsModal}
onClose={() => setShowSettingsModal(false)}
onSave={onSaveSiteConfig}
/>
<EmbeddedModal
siteInfo={appInfo.site}
isShow={showEmbedded}
onClose={() => setShowEmbedded(false)}
appBaseUrl={app_base_url}
accessToken={access_token}
/>
<CustomizeModal
isShow={showCustomizeModal}
onClose={() => setShowCustomizeModal(false)}
appId={appInfo.id}
api_base_url={appInfo.api_base_url}
mode={appInfo.mode}
/>
{
showAccessControl && (
<AccessControl
app={appDetail!}
onConfirm={handleAccessControlUpdate}
onClose={() => { setShowAccessControl(false) }}
/>
)
}
</>
)
: null}
</div>
)
}
export default AppCard

View File

@@ -0,0 +1,319 @@
import type { AppDetailResponse } from '@/models/app'
import type { SystemFeatures } from '@/types/feature'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { defaultSystemFeatures } from '@/types/feature'
import { basePath } from '@/utils/var'
import AppCard from '..'
const mockPush = vi.fn()
const mockFetchAppDetailDirect = vi.fn()
const mockSetAppDetail = vi.fn()
const mockWindowOpen = vi.fn()
let mockAppDetail: AppDetailResponse | undefined
let mockSystemFeatures: SystemFeatures
let mockAccessSubjects: { groups?: Array<{ id: string }>, members?: Array<{ id: string }> } | undefined
let mockWorkflowData: { graph?: { nodes?: Array<{ data: { type: string } }> } } | undefined
let mockAppContext: { isCurrentWorkspaceManager: boolean, isCurrentWorkspaceEditor: boolean }
let mockPathname = '/apps/app-1/overview'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, defaultValueOrOptions?: unknown, maybeOptions?: { ns?: string }) => {
const options = typeof defaultValueOrOptions === 'object' && defaultValueOrOptions && 'ns' in (defaultValueOrOptions as object)
? defaultValueOrOptions as { ns?: string }
: maybeOptions
return options?.ns ? `${options.ns}.${key}` : key
},
}),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail?: AppDetailResponse, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({
appDetail: mockAppDetail,
setAppDetail: mockSetAppDetail,
}),
}))
vi.mock('@/app/components/app-sidebar/basic', () => ({
default: ({ name, type }: { name: string, type: string }) => (
<div data-testid="app-basic">
<div>{name}</div>
<div>{type}</div>
</div>
),
}))
vi.mock('@/app/components/develop/secret-key/secret-key-button', () => ({
default: ({ appId }: { appId: string }) => <div>{`secret-key:${appId}`}</div>,
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <div>{`indicator:${color}`}</div>,
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => mockAppContext,
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: { systemFeatures: SystemFeatures }) => unknown) => selector({
systemFeatures: mockSystemFeatures,
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path?: string) => `https://docs.example.com${path ?? ''}`,
}))
vi.mock('@/next/navigation', () => ({
usePathname: () => mockPathname,
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: () => ({
data: mockAccessSubjects,
}),
}))
vi.mock('@/service/apps', () => ({
fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args),
}))
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: () => ({
data: mockWorkflowData,
}),
}))
vi.mock('../../settings', () => ({
default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => isShow
? (
<div>
<div>settings-modal</div>
<button onClick={onClose}>close-settings</button>
</div>
)
: null,
}))
vi.mock('../../embedded', () => ({
default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => isShow
? (
<div>
<div>embedded-modal</div>
<button onClick={onClose}>close-embedded</button>
</div>
)
: null,
}))
vi.mock('../../customize', () => ({
default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => isShow
? (
<div>
<div>customize-modal</div>
<button onClick={onClose}>close-customize</button>
</div>
)
: null,
}))
vi.mock('../../../app-access-control', () => ({
default: ({ onConfirm, onClose }: { onConfirm: () => void, onClose: () => void }) => (
<div>
<div>access-control-modal</div>
<button onClick={onConfirm}>confirm-access</button>
<button onClick={onClose}>close-access</button>
</div>
),
}))
const createSystemFeatures = (overrides: Partial<SystemFeatures> = {}): SystemFeatures => ({
...defaultSystemFeatures,
...overrides,
webapp_auth: {
...defaultSystemFeatures.webapp_auth,
...overrides.webapp_auth,
sso_config: {
...defaultSystemFeatures.webapp_auth.sso_config,
...overrides.webapp_auth?.sso_config,
},
},
})
const createAppInfo = (overrides: Partial<AppDetailResponse> = {}): AppDetailResponse => ({
access_mode: AccessMode.PUBLIC,
api_base_url: 'https://api.example.com',
enable_api: true,
enable_site: true,
icon: '🤖',
icon_background: '#ffffff',
icon_type: 'emoji',
id: 'app-1',
mode: AppModeEnum.CHAT,
name: 'Test app',
site: {
app_base_url: 'https://apps.example.com',
access_token: 'token-123',
},
...overrides,
} as AppDetailResponse)
describe('AppCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppDetail = createAppInfo()
mockSystemFeatures = createSystemFeatures({
webapp_auth: {
...defaultSystemFeatures.webapp_auth,
enabled: true,
},
})
mockAccessSubjects = {
groups: [{ id: 'group-1' }],
members: [],
}
mockWorkflowData = undefined
mockAppContext = {
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceManager: true,
}
mockPathname = '/apps/app-1/overview'
window.open = mockWindowOpen as unknown as typeof window.open
})
it('should render the webapp card with address, access, and operations', () => {
render(
<AppCard
appInfo={createAppInfo()}
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
/>,
)
expect(screen.getByText('appOverview.overview.appInfo.title')).toBeInTheDocument()
expect(screen.getByText('indicator:green')).toBeInTheDocument()
expect(screen.getByText(`https://apps.example.com${basePath}/chat/token-123`)).toBeInTheDocument()
expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /appOverview\.overview\.appInfo\.launch/i })).not.toBeDisabled()
})
it('should open customize, settings, and embedded modals from operations', () => {
render(
<AppCard
appInfo={createAppInfo()}
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /appOverview\.overview\.appInfo\.settings\.entry/i }))
expect(screen.getByText('settings-modal')).toBeInTheDocument()
fireEvent.click(screen.getByText('close-settings'))
expect(screen.queryByText('settings-modal')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /appOverview\.overview\.appInfo\.customize\.entry/i }))
expect(screen.getByText('customize-modal')).toBeInTheDocument()
fireEvent.click(screen.getByText('close-customize'))
expect(screen.queryByText('customize-modal')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /appOverview\.overview\.appInfo\.embedded\.entry/i }))
expect(screen.getByText('embedded-modal')).toBeInTheDocument()
fireEvent.click(screen.getByText('close-embedded'))
expect(screen.queryByText('embedded-modal')).not.toBeInTheDocument()
})
it('should open the regenerate dialog and confirm code regeneration', async () => {
const onGenerateCode = vi.fn().mockResolvedValue(undefined)
render(
<AppCard
appInfo={createAppInfo()}
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
onGenerateCode={onGenerateCode}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /appOverview\.overview\.appInfo\.regenerate/i }))
expect(screen.getByText('appOverview.overview.appInfo.regenerateNotice')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
await waitFor(() => {
expect(screen.queryByText('appOverview.overview.appInfo.regenerateNotice')).not.toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /appOverview\.overview\.appInfo\.regenerate/i }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.confirm/i }))
await waitFor(() => {
expect(onGenerateCode).toHaveBeenCalledTimes(1)
})
})
it('should route api cards to the develop page and render the secret key button', () => {
render(
<AppCard
appInfo={createAppInfo()}
cardType="api"
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
/>,
)
expect(screen.getByText('secret-key:app-1')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /appOverview\.overview\.apiInfo\.doc/i }))
expect(mockPush).toHaveBeenCalledWith('/apps/app-1/develop')
})
it('should open the access-control modal and refresh app detail after confirm', async () => {
mockAppDetail = createAppInfo({
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
})
mockFetchAppDetailDirect.mockResolvedValue(createAppInfo({
id: 'app-2',
access_mode: AccessMode.PUBLIC,
}))
render(
<AppCard
appInfo={createAppInfo({
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
})}
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
/>,
)
fireEvent.click(screen.getByText('app.accessControlDialog.accessItems.specific'))
expect(screen.getByText('access-control-modal')).toBeInTheDocument()
fireEvent.click(screen.getByText('close-access'))
expect(screen.queryByText('access-control-modal')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('app.accessControlDialog.accessItems.specific'))
fireEvent.click(screen.getByText('confirm-access'))
await waitFor(() => {
expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mockSetAppDetail).toHaveBeenCalledWith(expect.objectContaining({ id: 'app-2' }))
})
})
it('should render a disabled overlay when trigger mode blocks the card', () => {
const { container } = render(
<AppCard
appInfo={createAppInfo()}
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
triggerModeDisabled
triggerModeMessage={<span>blocked-in-trigger-mode</span>}
/>,
)
expect(container.querySelector('.cursor-not-allowed')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,197 @@
import type { AppDetailResponse } from '@/models/app'
import { fireEvent, render, screen } from '@testing-library/react'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import {
AppCardAccessSection,
AppCardAddressSection,
AppCardDisabledOverlay,
AppCardHeader,
AppCardOperations,
} from '../sections'
const mockWindowOpen = vi.fn()
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, defaultValueOrOptions?: unknown, maybeOptions?: { ns?: string }) => {
const options = typeof defaultValueOrOptions === 'object' && defaultValueOrOptions && 'ns' in (defaultValueOrOptions as object)
? defaultValueOrOptions as { ns?: string }
: maybeOptions
return options?.ns ? `${options.ns}.${key}` : key
},
}),
}))
vi.mock('@/app/components/app-sidebar/basic', () => ({
default: ({ name, type }: { name: string, type: string }) => (
<div data-testid="app-basic">
<div>{name}</div>
<div>{type}</div>
</div>
),
}))
vi.mock('@/app/components/base/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
vi.mock('@/app/components/base/ui/alert-dialog', () => ({
AlertDialog: ({ open, children }: { open: boolean, children: React.ReactNode }) => open ? <>{children}</> : null,
AlertDialogContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
AlertDialogTitle: ({ children }: { children: React.ReactNode }) => <>{children}</>,
AlertDialogDescription: ({ children }: { children: React.ReactNode }) => <>{children}</>,
AlertDialogActions: ({ children }: { children: React.ReactNode }) => <>{children}</>,
AlertDialogCancelButton: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => <button onClick={onClick}>{children}</button>,
AlertDialogConfirmButton: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => <button onClick={onClick}>{children}</button>,
}))
vi.mock('@/app/components/develop/secret-key/secret-key-button', () => ({
default: ({ appId }: { appId: string }) => <div>{`secret-key:${appId}`}</div>,
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <div>{`indicator:${color}`}</div>,
}))
const createAppInfo = (overrides: Partial<AppDetailResponse> = {}): AppDetailResponse => ({
access_mode: AccessMode.PUBLIC,
api_base_url: 'https://api.example.com',
enable_api: true,
enable_site: true,
icon: '🤖',
icon_background: '#ffffff',
icon_type: 'emoji',
id: 'app-1',
mode: AppModeEnum.CHAT,
name: 'Test app',
site: {
app_base_url: 'https://apps.example.com',
access_token: 'token-123',
},
...overrides,
} as AppDetailResponse)
describe('app-card-sections', () => {
beforeEach(() => {
vi.clearAllMocks()
window.open = mockWindowOpen as unknown as typeof window.open
})
it('should render disabled overlays with and without messages', () => {
const { container, rerender } = render(<AppCardDisabledOverlay triggerModeDisabled />)
expect(container.querySelector('.cursor-not-allowed')).toBeInTheDocument()
rerender(
<AppCardDisabledOverlay
triggerModeDisabled
triggerModeMessage={<span>blocked-message</span>}
/>,
)
expect(screen.getByText('blocked-message')).toBeInTheDocument()
})
it('should open the learn-more link from the disabled header tooltip', () => {
render(
<AppCardHeader
appInfo={createAppInfo({
mode: AppModeEnum.WORKFLOW,
})}
basicDescription="description"
basicName="title"
cardType="webapp"
learnMoreUrl="https://docs.example.com/use-dify/nodes/user-input"
runningStatus={false}
toggleDisabled
triggerModeDisabled={false}
appUnpublished
missingStartNode={false}
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
/>,
)
fireEvent.click(screen.getByText('appOverview.overview.appInfo.enableTooltip.learnMore'))
expect(mockWindowOpen).toHaveBeenCalledWith('https://docs.example.com/use-dify/nodes/user-input', '_blank')
})
it('should render api addresses without regenerate controls and handle webapp regenerate actions', () => {
const onCloseRegenerateDialog = vi.fn()
const onConfirmRegenerate = vi.fn().mockResolvedValue(undefined)
const { rerender } = render(
<AppCardAddressSection
addressLabel="address"
apiUrl="https://api.example.com"
appUrl="https://apps.example.com/app/chat/token-123"
genLoading={false}
isApp={false}
isCurrentWorkspaceManager={false}
isRegenerateDialogOpen={false}
onCloseRegenerateDialog={onCloseRegenerateDialog}
onConfirmRegenerate={onConfirmRegenerate}
onOpenRegenerateDialog={vi.fn()}
/>,
)
expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /appOverview\.overview\.appInfo\.regenerate/i })).not.toBeInTheDocument()
rerender(
<AppCardAddressSection
addressLabel="address"
apiUrl="https://api.example.com"
appUrl="https://apps.example.com/app/chat/token-123"
genLoading={false}
isApp
isCurrentWorkspaceManager
isRegenerateDialogOpen
onCloseRegenerateDialog={onCloseRegenerateDialog}
onConfirmRegenerate={onConfirmRegenerate}
onOpenRegenerateDialog={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.confirm/i }))
expect(onCloseRegenerateDialog).toHaveBeenCalledTimes(1)
expect(onConfirmRegenerate).toHaveBeenCalledTimes(1)
})
it('should render access warnings and api operations', () => {
const onOperationSelect = vi.fn()
render(
<>
<AppCardAccessSection
iconClassName="i-ri-lock-line"
isAppAccessSet={false}
label="private-access"
onClick={vi.fn()}
/>
<AppCardOperations
appId="app-1"
isApp={false}
operations={[
{ key: 'doc', label: 'Doc', iconClassName: 'i-ri-book-open-line', disabled: false },
{ key: 'launch', label: 'Launch', iconClassName: 'i-ri-external-link-line', disabled: true },
]}
onOperationSelect={onOperationSelect}
/>
</>,
)
expect(screen.getByText('app.publishApp.notSet')).toBeInTheDocument()
expect(screen.getByText('secret-key:app-1')).toBeInTheDocument()
expect(screen.getByText('appOverview.overview.appInfo.preUseReminder')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Doc/i }))
expect(onOperationSelect).toHaveBeenCalledWith('doc')
})
})

View File

@@ -11,6 +11,15 @@ const mockGetWorkflowEntryNode = getWorkflowEntryNode as MockedFunction<typeof g
// Mock entry node for testing (truthy value)
const mockEntryNode = { id: 'start-node', data: { type: 'start' } } as Node
type WorkflowState = {
graph?: {
nodes?: Array<{
data: {
type: string
}
}>
}
} | null | undefined
describe('App Card Toggle Logic', () => {
beforeEach(() => {
@@ -20,14 +29,14 @@ describe('App Card Toggle Logic', () => {
// Helper function that mirrors the actual logic from app-card.tsx
const calculateToggleState = (
appMode: string,
currentWorkflow: any,
currentWorkflow: WorkflowState,
isCurrentWorkspaceEditor: boolean,
isCurrentWorkspaceManager: boolean,
cardType: 'webapp' | 'api',
) => {
const isWorkflowApp = appMode === 'workflow'
const appUnpublished = isWorkflowApp && !currentWorkflow?.graph
const hasEntryNode = mockGetWorkflowEntryNode(currentWorkflow?.graph?.nodes || [])
const hasEntryNode = mockGetWorkflowEntryNode((currentWorkflow?.graph?.nodes || []) as Node[])
const missingEntryNode = isWorkflowApp && !hasEntryNode
const hasInsufficientPermissions = cardType === 'webapp' ? !isCurrentWorkspaceEditor : !isCurrentWorkspaceManager
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingEntryNode

View File

@@ -0,0 +1,255 @@
import type { AppDetailResponse } from '@/models/app'
import type { SystemFeatures } from '@/types/feature'
import { act, renderHook, waitFor } from '@testing-library/react'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { defaultSystemFeatures } from '@/types/feature'
import { basePath } from '@/utils/var'
import { useAppCard } from '../use-app-card'
const mockPush = vi.fn()
const mockFetchAppDetailDirect = vi.fn()
const mockSetAppDetail = vi.fn()
const mockWindowOpen = vi.fn()
let mockAppDetail: AppDetailResponse | undefined
let mockSystemFeatures: SystemFeatures
let mockAccessSubjects: { groups?: Array<{ id: string }>, members?: Array<{ id: string }> } | undefined
let mockWorkflowData: { graph?: { nodes?: Array<{ data: { type: string } }> } } | undefined
let mockAppContext: { isCurrentWorkspaceManager: boolean, isCurrentWorkspaceEditor: boolean }
let mockPathname = '/apps/app-1/overview'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, defaultValueOrOptions?: unknown, maybeOptions?: { ns?: string }) => {
const options = typeof defaultValueOrOptions === 'object' && defaultValueOrOptions && 'ns' in (defaultValueOrOptions as object)
? defaultValueOrOptions as { ns?: string }
: maybeOptions
return options?.ns ? `${options.ns}.${key}` : key
},
}),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail?: AppDetailResponse, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({
appDetail: mockAppDetail,
setAppDetail: mockSetAppDetail,
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => mockAppContext,
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: { systemFeatures: SystemFeatures }) => unknown) => selector({
systemFeatures: mockSystemFeatures,
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path?: string) => `https://docs.example.com${path ?? ''}`,
}))
vi.mock('@/next/navigation', () => ({
usePathname: () => mockPathname,
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: () => ({
data: mockAccessSubjects,
}),
}))
vi.mock('@/service/apps', () => ({
fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args),
}))
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: () => ({
data: mockWorkflowData,
}),
}))
const createSystemFeatures = (overrides: Partial<SystemFeatures> = {}): SystemFeatures => ({
...defaultSystemFeatures,
...overrides,
webapp_auth: {
...defaultSystemFeatures.webapp_auth,
...overrides.webapp_auth,
sso_config: {
...defaultSystemFeatures.webapp_auth.sso_config,
...overrides.webapp_auth?.sso_config,
},
},
})
const createAppInfo = (overrides: Partial<AppDetailResponse> = {}): AppDetailResponse => ({
access_mode: AccessMode.PUBLIC,
api_base_url: 'https://api.example.com',
enable_api: true,
enable_site: true,
icon: '🤖',
icon_background: '#ffffff',
icon_type: 'emoji',
id: 'app-1',
mode: AppModeEnum.CHAT,
name: 'Test app',
site: {
app_base_url: 'https://apps.example.com',
access_token: 'token-123',
},
...overrides,
} as AppDetailResponse)
describe('useAppCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppDetail = createAppInfo()
mockSystemFeatures = createSystemFeatures({
webapp_auth: {
...defaultSystemFeatures.webapp_auth,
enabled: true,
},
})
mockAccessSubjects = {
groups: [{ id: 'group-1' }],
members: [],
}
mockWorkflowData = undefined
mockAppContext = {
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceManager: true,
}
mockPathname = '/apps/app-1/overview'
window.open = mockWindowOpen as unknown as typeof window.open
})
it('should build webapp operations and derived labels for a running app', () => {
const { result } = renderHook(() => useAppCard({
appInfo: createAppInfo(),
cardType: 'webapp',
}))
expect(result.current.basicName).toBe('appOverview.overview.appInfo.title')
expect(result.current.basicDescription).toBe('appOverview.overview.appInfo.explanation')
expect(result.current.addressLabel).toBe('appOverview.overview.appInfo.accessibleAddress')
expect(result.current.appUrl).toBe(`https://apps.example.com${basePath}/chat/token-123`)
expect(result.current.learnMoreUrl).toBe('https://docs.example.com/use-dify/nodes/user-input')
expect(result.current.operations.map(item => item.key)).toEqual(['launch', 'embedded', 'customize', 'settings'])
expect(result.current.operations.every(item => item.disabled === false)).toBe(true)
})
it('should mark workflow cards as minimal when the start node is missing', () => {
mockWorkflowData = {
graph: {
nodes: [{ data: { type: 'llm' } }],
},
}
const { result } = renderHook(() => useAppCard({
appInfo: createAppInfo({
mode: AppModeEnum.WORKFLOW,
}),
cardType: 'webapp',
}))
expect(result.current.isMinimalState).toBe(true)
expect(result.current.runningStatus).toBe(false)
expect(result.current.toggleDisabled).toBe(true)
expect(result.current.missingStartNode).toBe(true)
})
it('should open launch links, route api docs, and toggle modal operations', () => {
const { result } = renderHook(() => useAppCard({
appInfo: createAppInfo(),
cardType: 'webapp',
}))
act(() => {
result.current.handleOperationSelect('launch')
result.current.handleOperationSelect('embedded')
result.current.handleOperationSelect('settings')
result.current.handleOperationSelect('customize')
})
expect(mockWindowOpen).toHaveBeenCalledWith(`https://apps.example.com${basePath}/chat/token-123`, '_blank')
expect(result.current.activeModal).toBe('customize')
const apiHook = renderHook(() => useAppCard({
appInfo: createAppInfo(),
cardType: 'api',
}))
act(() => {
apiHook.result.current.handleOperationSelect('doc')
})
expect(mockPush).toHaveBeenCalledWith('/apps/app-1/develop')
})
it('should run regenerate actions and close the confirmation dialog', async () => {
const onGenerateCode = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() => useAppCard({
appInfo: createAppInfo(),
cardType: 'webapp',
onGenerateCode,
}))
act(() => {
result.current.setShowConfirmDelete(true)
})
await act(async () => {
await result.current.handleGenerateCode()
})
expect(onGenerateCode).toHaveBeenCalledTimes(1)
expect(result.current.genLoading).toBe(false)
expect(result.current.showConfirmDelete).toBe(false)
})
it('should flag unset specific-group access and refresh app detail after access updates', async () => {
mockAppDetail = createAppInfo({
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
})
mockAccessSubjects = {
groups: [],
members: [],
}
mockFetchAppDetailDirect.mockResolvedValue(createAppInfo({
id: 'app-2',
access_mode: AccessMode.PUBLIC,
}))
const { result } = renderHook(() => useAppCard({
appInfo: mockAppDetail!,
cardType: 'webapp',
}))
expect(result.current.isAppAccessSet).toBe(false)
expect(result.current.accessDisplay).toMatchObject({
iconClassName: 'i-ri-lock-line',
label: 'app.accessControlDialog.accessItems.specific',
})
act(() => {
result.current.handleClickAccessControl()
})
expect(result.current.showAccessControl).toBe(true)
await act(async () => {
await result.current.handleAccessControlUpdate()
})
await waitFor(() => {
expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mockSetAppDetail).toHaveBeenCalledWith(expect.objectContaining({ id: 'app-2' }))
})
expect(result.current.showAccessControl).toBe(false)
})
})

View File

@@ -0,0 +1,157 @@
'use client'
import type { IAppCardProps } from './types'
import AccessControl from '../../app-access-control'
import CustomizeModal from '../customize'
import EmbeddedModal from '../embedded'
import SettingsModal from '../settings'
import { AppCardAccessSection, AppCardAddressSection, AppCardDisabledOverlay, AppCardHeader, AppCardOperations } from './sections'
import { useAppCard } from './use-app-card'
function AppCard({
appInfo,
isInPanel,
cardType = 'webapp',
customBgColor,
triggerModeDisabled = false,
triggerModeMessage = '',
onChangeStatus,
onSaveSiteConfig,
onGenerateCode,
className,
}: IAppCardProps) {
const {
accessDisplay,
accessToken,
activeModal,
addressLabel,
apiUrl,
appBaseUrl,
appDetail,
appMode,
appUnpublished,
appUrl,
basicDescription,
basicName,
genLoading,
handleAccessControlUpdate,
handleClickAccessControl,
handleGenerateCode,
handleOperationSelect,
isApp,
isAppAccessSet,
isCurrentWorkspaceManager,
isMinimalState,
learnMoreUrl,
missingStartNode,
operations,
runningStatus,
setActiveModal,
setShowAccessControl,
setShowConfirmDelete,
showAccessControl,
showConfirmDelete,
systemFeatures,
toggleDisabled,
} = useAppCard({
appInfo,
cardType,
onGenerateCode,
triggerModeDisabled,
})
return (
<div
className={
`${isInPanel ? 'border-l-[0.5px] border-t' : 'border-[0.5px] shadow-xs'} w-full max-w-full rounded-xl border-effects-highlight ${className ?? ''} ${isMinimalState ? 'h-12' : ''}`
}
>
<div className={`${customBgColor ?? 'bg-background-default'} relative rounded-xl ${triggerModeDisabled ? 'opacity-60' : ''}`}>
<AppCardDisabledOverlay triggerModeDisabled={triggerModeDisabled} triggerModeMessage={triggerModeMessage} />
<div className={`flex w-full flex-col items-start justify-center gap-3 self-stretch p-3 ${isMinimalState ? 'border-0' : 'border-b-[0.5px] border-divider-subtle'}`}>
<AppCardHeader
appInfo={appInfo}
basicDescription={basicDescription}
basicName={basicName}
cardType={cardType}
learnMoreUrl={learnMoreUrl}
runningStatus={runningStatus}
toggleDisabled={toggleDisabled}
triggerModeDisabled={triggerModeDisabled}
triggerModeMessage={triggerModeMessage}
appUnpublished={appUnpublished}
missingStartNode={missingStartNode}
onChangeStatus={onChangeStatus}
/>
{!isMinimalState && (
<AppCardAddressSection
addressLabel={addressLabel}
apiUrl={apiUrl}
appUrl={appUrl}
genLoading={genLoading}
isApp={isApp}
isCurrentWorkspaceManager={isCurrentWorkspaceManager}
isRegenerateDialogOpen={showConfirmDelete}
onCloseRegenerateDialog={() => setShowConfirmDelete(false)}
onConfirmRegenerate={handleGenerateCode}
onOpenRegenerateDialog={() => setShowConfirmDelete(true)}
/>
)}
{!isMinimalState && isApp && systemFeatures.webapp_auth.enabled && appDetail && accessDisplay && (
<AppCardAccessSection
iconClassName={accessDisplay.iconClassName}
isAppAccessSet={isAppAccessSet}
label={accessDisplay.label}
onClick={handleClickAccessControl}
/>
)}
</div>
{!isMinimalState && (
<AppCardOperations
appId={appInfo.id}
isApp={isApp}
operations={operations}
onOperationSelect={handleOperationSelect}
/>
)}
</div>
{isApp
? (
<>
<SettingsModal
isChat={appMode === 'chat'}
appInfo={appInfo}
isShow={activeModal === 'settings'}
onClose={() => setActiveModal(null)}
onSave={onSaveSiteConfig}
/>
<EmbeddedModal
siteInfo={appInfo.site}
isShow={activeModal === 'embedded'}
onClose={() => setActiveModal(null)}
appBaseUrl={appBaseUrl}
accessToken={accessToken}
/>
<CustomizeModal
isShow={activeModal === 'customize'}
onClose={() => setActiveModal(null)}
appId={appInfo.id}
api_base_url={appInfo.api_base_url}
mode={appInfo.mode}
/>
{
showAccessControl && (
<AccessControl
app={appDetail!}
onConfirm={handleAccessControlUpdate}
onClose={() => { setShowAccessControl(false) }}
/>
)
}
</>
)
: null}
</div>
)
}
export default AppCard

View File

@@ -0,0 +1,320 @@
'use client'
import type { ReactElement, ReactNode } from 'react'
import type { AppCardOperation } from './use-app-card'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { useTranslation } from 'react-i18next'
import AppBasic from '@/app/components/app-sidebar/basic'
import Button from '@/app/components/base/button'
import CopyFeedback from '@/app/components/base/copy-feedback'
import Divider from '@/app/components/base/divider'
import ShareQRCode from '@/app/components/base/qrcode'
import Switch from '@/app/components/base/switch'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import SecretKeyButton from '@/app/components/develop/secret-key/secret-key-button'
import Indicator from '@/app/components/header/indicator'
import { cn } from '@/utils/classnames'
import style from '../style.module.css'
type AppCardDisabledOverlayProps = {
triggerModeDisabled: boolean
triggerModeMessage?: ReactNode
}
type AppCardHeaderProps = {
appInfo: AppDetailResponse & Partial<AppSSO>
basicDescription: string
basicName: string
cardType: 'api' | 'webapp'
learnMoreUrl: string
runningStatus: boolean
toggleDisabled: boolean
triggerModeDisabled: boolean
triggerModeMessage?: ReactNode
appUnpublished: boolean
missingStartNode: boolean
onChangeStatus: (value: boolean) => Promise<void>
}
type AppCardAddressSectionProps = {
addressLabel: string
apiUrl?: string | null
appUrl: string
genLoading: boolean
isApp: boolean
isCurrentWorkspaceManager: boolean
isRegenerateDialogOpen: boolean
onCloseRegenerateDialog: () => void
onConfirmRegenerate: () => Promise<void>
onOpenRegenerateDialog: () => void
}
type AppCardAccessSectionProps = {
iconClassName: string
isAppAccessSet: boolean
label: string
onClick: () => void
}
type AppCardOperationsProps = {
appId: string
isApp: boolean
operations: AppCardOperation[]
onOperationSelect: (key: AppCardOperation['key']) => void
}
const renderTooltipTrigger = (content: ReactNode) => (
<TooltipTrigger render={content as ReactElement} />
)
export const AppCardDisabledOverlay = ({
triggerModeDisabled,
triggerModeMessage,
}: AppCardDisabledOverlayProps) => {
if (!triggerModeDisabled)
return null
const overlay = <div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true" />
if (!triggerModeMessage)
return overlay
return (
<Tooltip>
{renderTooltipTrigger(overlay)}
<TooltipContent placement="right" popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg">
{triggerModeMessage}
</TooltipContent>
</Tooltip>
)
}
export const AppCardHeader = ({
appInfo,
basicDescription,
basicName,
cardType,
learnMoreUrl,
runningStatus,
toggleDisabled,
triggerModeDisabled,
triggerModeMessage,
appUnpublished,
missingStartNode,
onChangeStatus,
}: AppCardHeaderProps) => {
const { t } = useTranslation()
const switchTooltipContent = toggleDisabled
? (
triggerModeDisabled && triggerModeMessage
? triggerModeMessage
: (appUnpublished || missingStartNode)
? (
<>
<div className="mb-1 text-xs font-normal text-text-secondary">
{t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
</div>
<div
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
onClick={() => window.open(learnMoreUrl, '_blank')}
>
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
</div>
</>
)
: null
)
: null
const switchNode = (
<div>
<Switch value={runningStatus} onChange={onChangeStatus} disabled={toggleDisabled} />
</div>
)
return (
<div className="flex w-full items-center gap-3 self-stretch">
<AppBasic
iconType={cardType}
icon={appInfo.icon}
icon_background={appInfo.icon_background}
name={basicName}
hideType
type={basicDescription}
/>
<div className="flex shrink-0 items-center gap-1">
<Indicator color={runningStatus ? 'green' : 'yellow'} />
<div className={cn(runningStatus ? 'text-text-success' : 'text-text-warning', 'system-xs-semibold-uppercase')}>
{runningStatus
? t('overview.status.running', { ns: 'appOverview' })
: t('overview.status.disable', { ns: 'appOverview' })}
</div>
</div>
{switchTooltipContent
? (
<Tooltip>
{renderTooltipTrigger(switchNode)}
<TooltipContent
placement="right"
sideOffset={24}
popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg"
>
{switchTooltipContent}
</TooltipContent>
</Tooltip>
)
: switchNode}
</div>
)
}
export const AppCardAddressSection = ({
addressLabel,
apiUrl,
appUrl,
genLoading,
isApp,
isCurrentWorkspaceManager,
isRegenerateDialogOpen,
onCloseRegenerateDialog,
onConfirmRegenerate,
onOpenRegenerateDialog,
}: AppCardAddressSectionProps) => {
const { t } = useTranslation()
const address = isApp ? appUrl : (apiUrl ?? '')
const refreshButton = (
<button
type="button"
aria-label={t('overview.appInfo.regenerate', { ns: 'appOverview' })}
className="h-6 w-6 cursor-pointer rounded-md hover:bg-state-base-hover"
onClick={onOpenRegenerateDialog}
>
<div className={cn('h-full w-full', style.refreshIcon, genLoading && style.generateLogo)} />
</button>
)
return (
<div className="flex flex-col items-start justify-center self-stretch">
<div className="pb-1 text-text-tertiary system-xs-medium">{addressLabel}</div>
<div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2">
<div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1">
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-secondary">
{address}
</div>
</div>
<CopyFeedback content={address} className="!size-6" />
{isApp && <ShareQRCode content={appUrl} />}
{isApp && <Divider type="vertical" className="!mx-0.5 !h-3.5 shrink-0" />}
{isApp && isCurrentWorkspaceManager && (
<AlertDialog open={isRegenerateDialogOpen} onOpenChange={open => !open && onCloseRegenerateDialog()}>
<Tooltip>
{renderTooltipTrigger(refreshButton)}
<TooltipContent>{t('overview.appInfo.regenerate', { ns: 'appOverview' })}</TooltipContent>
</Tooltip>
<AlertDialogContent className="w-[480px]">
<div className="flex flex-col items-start gap-2 self-stretch pb-4 pl-6 pr-6 pt-6">
<AlertDialogTitle className="w-full text-text-primary title-2xl-semi-bold">
{t('overview.appInfo.regenerate', { ns: 'appOverview' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full whitespace-pre-wrap break-words text-text-tertiary system-md-regular">
{t('overview.appInfo.regenerateNotice', { ns: 'appOverview' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={genLoading} onClick={onCloseRegenerateDialog}>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={genLoading} disabled={genLoading} onClick={onConfirmRegenerate}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
)
}
export const AppCardAccessSection = ({
iconClassName,
isAppAccessSet,
label,
onClick,
}: AppCardAccessSectionProps) => {
const { t } = useTranslation()
return (
<div className="flex flex-col items-start justify-center self-stretch">
<div className="pb-1 text-text-tertiary system-xs-medium">{t('publishApp.title', { ns: 'app' })}</div>
<div
className="flex h-9 w-full cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2"
onClick={onClick}
>
<div className="flex grow items-center gap-x-1.5 pr-1">
<span className={cn(iconClassName, 'h-4 w-4 shrink-0 text-text-secondary')} aria-hidden="true" />
<p className="text-text-secondary system-sm-medium">{label}</p>
</div>
{!isAppAccessSet && <p className="shrink-0 text-text-tertiary system-xs-regular">{t('publishApp.notSet', { ns: 'app' })}</p>}
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<span className="i-ri-arrow-right-s-line h-4 w-4 text-text-quaternary" aria-hidden="true" />
</div>
</div>
</div>
)
}
export const AppCardOperations = ({
appId,
isApp,
operations,
onOperationSelect,
}: AppCardOperationsProps) => {
const { t } = useTranslation()
return (
<div className="flex items-center gap-1 self-stretch p-3">
{!isApp && <SecretKeyButton appId={appId} />}
{operations.map(operation => (
<Button
className="mr-1 min-w-[88px]"
size="small"
variant="ghost"
key={operation.key}
onClick={() => onOperationSelect(operation.key)}
disabled={operation.disabled}
>
{operation.disabled
? (
<Tooltip>
{renderTooltipTrigger(
<div className="flex items-center justify-center gap-[1px]">
<span className={cn(operation.iconClassName, 'h-3.5 w-3.5')} aria-hidden="true" />
<div className="px-[3px] text-components-button-ghost-text-disabled system-xs-medium">{operation.label}</div>
</div>,
)}
<TooltipContent popupClassName="mt-[-8px]">
{t('overview.appInfo.preUseReminder', { ns: 'appOverview' })}
</TooltipContent>
</Tooltip>
)
: (
<div className="flex items-center justify-center gap-[1px]">
<span className={cn(operation.iconClassName, 'h-3.5 w-3.5')} aria-hidden="true" />
<div className="px-[3px] text-text-tertiary system-xs-medium">{operation.label}</div>
</div>
)}
</Button>
))}
</div>
)
}

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
import type { ConfigParams } from '../settings'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
export type AppCardType = 'api' | 'webapp'
export type IAppCardProps = {
className?: string
appInfo: AppDetailResponse & Partial<AppSSO>
isInPanel?: boolean
cardType?: AppCardType
customBgColor?: string
triggerModeDisabled?: boolean
triggerModeMessage?: ReactNode
onChangeStatus: (val: boolean) => Promise<void>
onSaveSiteConfig?: (params: ConfigParams) => Promise<void>
onGenerateCode?: () => Promise<void>
}

View File

@@ -0,0 +1,264 @@
import type { IAppCardProps } from './types'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useDocLink } from '@/context/i18n'
import { AccessMode } from '@/models/access-control'
import { usePathname, useRouter } from '@/next/navigation'
import { useAppWhiteListSubjects } from '@/service/access-control'
import { fetchAppDetailDirect } from '@/service/apps'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
import { asyncRunSafe } from '@/utils'
import { basePath } from '@/utils/var'
type AppCardOperationKey = 'launch' | 'doc' | 'embedded' | 'customize' | 'settings'
export type AppCardModalKey = 'settings' | 'embedded' | 'customize' | null
export type AppCardOperation = {
key: AppCardOperationKey
label: string
iconClassName: string
disabled: boolean
}
export type AppCardAccessDisplay = {
iconClassName: string
label: string
}
type UseAppCardOptions = Pick<
IAppCardProps,
'appInfo' | 'cardType' | 'onGenerateCode' | 'triggerModeDisabled'
>
const OPERATION_ICON_MAP: Record<AppCardOperationKey, string> = {
launch: 'i-ri-external-link-line',
doc: 'i-ri-book-open-line',
embedded: 'i-ri-window-line',
customize: 'i-ri-paint-brush-line',
settings: 'i-ri-equalizer-2-line',
}
const ACCESS_MODE_ICON_MAP: Record<AccessMode, string> = {
[AccessMode.ORGANIZATION]: 'i-ri-building-line',
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: 'i-ri-lock-line',
[AccessMode.PUBLIC]: 'i-ri-global-line',
[AccessMode.EXTERNAL_MEMBERS]: 'i-ri-verified-badge-line',
}
const ACCESS_MODE_LABEL_KEY_MAP: Partial<Record<AccessMode, string>> = {
[AccessMode.ORGANIZATION]: 'accessControlDialog.accessItems.organization',
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: 'accessControlDialog.accessItems.specific',
[AccessMode.PUBLIC]: 'accessControlDialog.accessItems.anyone',
[AccessMode.EXTERNAL_MEMBERS]: 'accessControlDialog.accessItems.external',
}
export const useAppCard = ({
appInfo,
cardType = 'webapp',
onGenerateCode,
triggerModeDisabled = false,
}: UseAppCardOptions) => {
const router = useRouter()
const pathname = usePathname()
const { t } = useTranslation()
const docLink = useDocLink()
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
const { data: currentWorkflow } = useAppWorkflow(appInfo.mode === AppModeEnum.WORKFLOW ? appInfo.id : '')
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { data: appAccessSubjects } = useAppWhiteListSubjects(
appDetail?.id,
systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS,
)
const [activeModal, setActiveModal] = useState<AppCardModalKey>(null)
const [genLoading, setGenLoading] = useState(false)
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showAccessControl, setShowAccessControl] = useState(false)
const isApp = cardType === 'webapp'
const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW
const appUnpublished = isWorkflowApp && !currentWorkflow?.graph
const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start)
const missingStartNode = isWorkflowApp && !hasStartNode
const hasInsufficientPermissions = isApp ? !isCurrentWorkspaceEditor : !isCurrentWorkspaceManager
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled
const runningStatus = (appUnpublished || missingStartNode) ? false : (isApp ? appInfo.enable_site : appInfo.enable_api)
const isMinimalState = appUnpublished || missingStartNode
const { app_base_url, access_token } = appInfo.site ?? {}
const appMode = (appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW)
? AppModeEnum.CHAT
: appInfo.mode
const appUrl = `${app_base_url}${basePath}/${appMode}/${access_token}`
const apiUrl = appInfo.api_base_url
const learnMoreUrl = docLink('/use-dify/nodes/user-input')
const basicName = isApp
? t('overview.appInfo.title', { ns: 'appOverview' })
: t('overview.apiInfo.title', { ns: 'appOverview' })
const basicDescription = isApp
? t('overview.appInfo.explanation', { ns: 'appOverview' })
: t('overview.apiInfo.explanation', { ns: 'appOverview' })
const addressLabel = isApp
? t('overview.appInfo.accessibleAddress', { ns: 'appOverview' })
: t('overview.apiInfo.accessibleAddress', { ns: 'appOverview' })
const isAppAccessSet = useMemo(() => {
if (!appDetail || !appAccessSubjects)
return true
if (appDetail.access_mode !== AccessMode.SPECIFIC_GROUPS_MEMBERS)
return true
return appAccessSubjects.groups?.length !== 0 || appAccessSubjects.members?.length !== 0
}, [appAccessSubjects, appDetail])
const accessDisplay = useMemo<AppCardAccessDisplay | null>(() => {
if (!appDetail)
return null
const labelKey = ACCESS_MODE_LABEL_KEY_MAP[appDetail.access_mode]
if (!labelKey)
return null
return {
iconClassName: ACCESS_MODE_ICON_MAP[appDetail.access_mode],
label: t(labelKey, labelKey, { ns: 'app' }) as string,
}
}, [appDetail, t])
const operations = useMemo<AppCardOperation[]>(() => {
const items: AppCardOperationKey[] = isApp ? ['launch'] : ['doc']
if (isApp && appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW)
items.push('embedded')
if (isApp)
items.push('customize')
if (isApp && isCurrentWorkspaceEditor)
items.push('settings')
return items.map((key) => {
const label = (() => {
switch (key) {
case 'launch':
return t('overview.appInfo.launch', { ns: 'appOverview' })
case 'embedded':
return t('overview.appInfo.embedded.entry', { ns: 'appOverview' })
case 'customize':
return t('overview.appInfo.customize.entry', { ns: 'appOverview' })
case 'settings':
return t('overview.appInfo.settings.entry', { ns: 'appOverview' })
default:
return t('overview.apiInfo.doc', { ns: 'appOverview' })
}
})()
return {
key,
label,
iconClassName: OPERATION_ICON_MAP[key],
disabled: triggerModeDisabled ? true : key === 'settings' ? false : !runningStatus,
}
})
}, [appInfo.mode, isApp, isCurrentWorkspaceEditor, runningStatus, t, triggerModeDisabled])
const handleOperationSelect = useCallback((key: AppCardOperationKey) => {
switch (key) {
case 'launch':
window.open(appUrl, '_blank')
return
case 'customize':
setActiveModal('customize')
return
case 'settings':
setActiveModal('settings')
return
case 'embedded':
setActiveModal('embedded')
return
default: {
const pathSegments = pathname.split('/')
pathSegments.pop()
router.push(`${pathSegments.join('/')}/develop`)
}
}
}, [appUrl, pathname, router])
const handleGenerateCode = useCallback(async () => {
if (!onGenerateCode)
return
setGenLoading(true)
await asyncRunSafe(onGenerateCode())
setGenLoading(false)
setShowConfirmDelete(false)
}, [onGenerateCode])
const handleClickAccessControl = useCallback(() => {
if (!appDetail)
return
setShowAccessControl(true)
}, [appDetail])
const handleAccessControlUpdate = useCallback(async () => {
if (!appDetail)
return
try {
const res = await fetchAppDetailDirect({ url: '/apps', id: appDetail.id })
setAppDetail(res)
setShowAccessControl(false)
}
catch (error) {
console.error('Failed to fetch app detail:', error)
}
}, [appDetail, setAppDetail])
return {
accessDisplay,
activeModal,
addressLabel,
apiUrl,
appMode,
appUrl,
basicDescription,
basicName,
genLoading,
handleAccessControlUpdate,
handleClickAccessControl,
handleGenerateCode,
handleOperationSelect,
isApp,
isAppAccessSet,
isCurrentWorkspaceManager,
isMinimalState,
learnMoreUrl,
missingStartNode,
operations,
runningStatus,
setActiveModal,
setShowAccessControl,
setShowConfirmDelete,
showAccessControl,
showConfirmDelete,
toggleDisabled,
appBaseUrl: app_base_url,
accessToken: access_token,
appDetail,
appInfo,
appUnpublished,
cardType,
hasInsufficientPermissions,
systemFeatures,
triggerModeDisabled,
}
}

View File

@@ -1,512 +0,0 @@
'use client'
import type { Dayjs } from 'dayjs'
import type { EChartsOption } from 'echarts'
import type { FC } from 'react'
import type { AppDailyConversationsResponse, AppDailyEndUsersResponse, AppDailyMessagesResponse, AppTokenCostsResponse } from '@/models/app'
import dayjs from 'dayjs'
import Decimal from 'decimal.js'
import ReactECharts from 'echarts-for-react'
import { get } from 'es-toolkit/compat'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Basic from '@/app/components/app-sidebar/basic'
import Loading from '@/app/components/base/loading'
import {
useAppAverageResponseTime,
useAppAverageSessionInteractions,
useAppDailyConversations,
useAppDailyEndUsers,
useAppDailyMessages,
useAppSatisfactionRate,
useAppTokenCosts,
useAppTokensPerSecond,
useWorkflowAverageInteractions,
useWorkflowDailyConversations,
useWorkflowDailyTerminals,
useWorkflowTokenCosts,
} from '@/service/use-apps'
import { formatNumber } from '@/utils/format'
const valueFormatter = (v: string | number) => v
const COLOR_TYPE_MAP = {
green: {
lineColor: 'rgba(6, 148, 162, 1)',
bgColor: ['rgba(6, 148, 162, 0.2)', 'rgba(67, 174, 185, 0.08)'],
},
orange: {
lineColor: 'rgba(255, 138, 76, 1)',
bgColor: ['rgba(254, 145, 87, 0.2)', 'rgba(255, 138, 76, 0.1)'],
},
blue: {
lineColor: 'rgba(28, 100, 242, 1)',
bgColor: ['rgba(28, 100, 242, 0.3)', 'rgba(28, 100, 242, 0.1)'],
},
}
const COMMON_COLOR_MAP = {
label: '#9CA3AF',
splitLineLight: '#F3F4F6',
splitLineDark: '#E5E7EB',
}
type IColorType = 'green' | 'orange' | 'blue'
type IChartType = 'messages' | 'conversations' | 'endUsers' | 'costs' | 'workflowCosts'
type IChartConfigType = { colorType: IColorType, showTokens?: boolean }
const commonDateFormat = 'MMM D, YYYY'
const CHART_TYPE_CONFIG: Record<string, IChartConfigType> = {
messages: {
colorType: 'green',
},
conversations: {
colorType: 'green',
},
endUsers: {
colorType: 'orange',
},
costs: {
colorType: 'blue',
showTokens: true,
},
workflowCosts: {
colorType: 'blue',
},
}
const sum = (arr: Decimal.Value[]): number => {
return Decimal.sum(...arr).toNumber()
}
const defaultPeriod = {
start: dayjs().subtract(7, 'day').format(commonDateFormat),
end: dayjs().format(commonDateFormat),
}
export type PeriodParams = {
name: string
query?: {
start: string
end: string
}
}
export type TimeRange = {
start: Dayjs
end: Dayjs
}
export type PeriodParamsWithTimeRange = {
name: string
query?: TimeRange
}
export type IBizChartProps = {
period: PeriodParams
id: string
}
export type IChartProps = {
className?: string
basicInfo: { title: string, explanation: string, timePeriod: string }
valueKey?: string
isAvg?: boolean
unit?: string
yMax?: number
chartType: IChartType
chartData: AppDailyMessagesResponse | AppDailyConversationsResponse | AppDailyEndUsersResponse | AppTokenCostsResponse | { data: Array<{ date: string, count: number }> }
}
const Chart: React.FC<IChartProps> = ({
basicInfo: { title, explanation, timePeriod },
chartType = 'conversations',
chartData,
valueKey,
isAvg,
unit = '',
yMax,
className,
}) => {
const { t } = useTranslation()
const statistics = chartData.data
const statisticsLen = statistics.length
const markLineLength = statisticsLen >= 2 ? statisticsLen - 2 : statisticsLen
const extraDataForMarkLine = Array.from({ length: markLineLength }, () => '1')
extraDataForMarkLine.push('')
extraDataForMarkLine.unshift('')
const xData = statistics.map(({ date }) => date)
const yField = valueKey || Object.keys(statistics[0]).find(name => name.includes('count')) || ''
const yData = statistics.map((item) => {
// @ts-expect-error field is valid
return item[yField] || 0
})
const options: EChartsOption = {
dataset: {
dimensions: ['date', yField],
source: statistics,
},
grid: { top: 8, right: 36, bottom: 10, left: 25, containLabel: true },
tooltip: {
trigger: 'item',
position: 'top',
borderWidth: 0,
},
xAxis: [{
type: 'category',
boundaryGap: false,
axisLabel: {
color: COMMON_COLOR_MAP.label,
hideOverlap: true,
overflow: 'break',
formatter(value) {
return dayjs(value).format(commonDateFormat)
},
},
axisLine: { show: false },
axisTick: { show: false },
splitLine: {
show: true,
lineStyle: {
color: COMMON_COLOR_MAP.splitLineLight,
width: 1,
type: [10, 10],
},
interval(index) {
return index === 0 || index === xData.length - 1
},
},
}, {
position: 'bottom',
boundaryGap: false,
data: extraDataForMarkLine,
axisLabel: { show: false },
axisLine: { show: false },
axisTick: { show: false },
splitLine: {
show: true,
lineStyle: {
color: COMMON_COLOR_MAP.splitLineDark,
},
interval(_index, value) {
return !!value
},
},
}],
yAxis: {
max: yMax ?? 'dataMax',
type: 'value',
axisLabel: { color: COMMON_COLOR_MAP.label, hideOverlap: true },
splitLine: {
lineStyle: {
color: COMMON_COLOR_MAP.splitLineLight,
},
},
},
series: [
{
type: 'line',
showSymbol: true,
// symbol: 'circle',
// triggerLineEvent: true,
symbolSize: 4,
lineStyle: {
color: COLOR_TYPE_MAP[CHART_TYPE_CONFIG[chartType].colorType].lineColor,
width: 2,
},
itemStyle: {
color: COLOR_TYPE_MAP[CHART_TYPE_CONFIG[chartType].colorType].lineColor,
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0,
color: COLOR_TYPE_MAP[CHART_TYPE_CONFIG[chartType].colorType].bgColor[0],
}, {
offset: 1,
color: COLOR_TYPE_MAP[CHART_TYPE_CONFIG[chartType].colorType].bgColor[1],
}],
global: false,
},
},
tooltip: {
padding: [8, 12, 8, 12],
formatter(params) {
return `<div style='color:#6B7280;font-size:12px'>${params.name}</div>
<div style='font-size:14px;color:#1F2A37'>${valueFormatter((params.data as any)[yField])}
${!CHART_TYPE_CONFIG[chartType].showTokens
? ''
: `<span style='font-size:12px'>
<span style='margin-left:4px;color:#6B7280'>(</span>
<span style='color:#FF8A4C'>~$${get(params.data, 'total_price', 0)}</span>
<span style='color:#6B7280'>)</span>
</span>`}
</div>`
},
},
},
],
}
const sumData = isAvg ? (sum(yData) / yData.length) : sum(yData)
return (
<div className={`flex w-full flex-col rounded-xl bg-components-chart-bg px-6 py-4 shadow-xs ${className ?? ''}`}>
<div className="mb-3">
<Basic name={title} type={timePeriod} hoverTip={explanation} />
</div>
<div className="mb-4 flex-1">
<Basic
isExtraInLine={CHART_TYPE_CONFIG[chartType].showTokens}
name={chartType !== 'costs' ? (`${sumData.toLocaleString()} ${unit}`) : `${sumData < 1000 ? sumData : (`${formatNumber(Math.round(sumData / 1000))}k`)}`}
type={!CHART_TYPE_CONFIG[chartType].showTokens
? ''
: (
<span>
{t('analysis.tokenUsage.consumed', { ns: 'appOverview' })}
{' '}
Tokens
<span className="text-sm">
<span className="ml-1 text-text-tertiary">(</span>
<span className="text-orange-400">
~
{sum(statistics.map(item => Number.parseFloat(String(get(item, 'total_price', '0'))))).toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 4 })}
</span>
<span className="text-text-tertiary">)</span>
</span>
</span>
)}
textStyle={{ main: `!text-3xl !font-normal ${sumData === 0 ? '!text-text-quaternary' : ''}` }}
/>
</div>
<ReactECharts option={options} style={{ height: 160 }} />
</div>
)
}
const getDefaultChartData = ({ start, end, key = 'count' }: { start: string, end: string, key?: string }) => {
const diffDays = dayjs(end).diff(dayjs(start), 'day')
return Array.from({ length: diffDays || 1 }, () => ({ date: '', [key]: 0 })).map((item, index) => {
item.date = dayjs(start).add(index, 'day').format(commonDateFormat)
return item
})
}
export const MessagesChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation()
const { data: response, isLoading } = useAppDailyMessages(id, period.query)
if (isLoading || !response)
return <Loading />
const noDataFlag = !response.data || response.data.length === 0
return (
<Chart
basicInfo={{ title: t('analysis.totalMessages.title', { ns: 'appOverview' }), explanation: t('analysis.totalMessages.explanation', { ns: 'appOverview' }), timePeriod: period.name }}
chartData={!noDataFlag ? response : { data: getDefaultChartData(period.query ?? defaultPeriod) } as any}
chartType="messages"
{...(noDataFlag && { yMax: 500 })}
/>
)
}
export const ConversationsChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation()
const { data: response, isLoading } = useAppDailyConversations(id, period.query)
if (isLoading || !response)
return <Loading />
const noDataFlag = !response.data || response.data.length === 0
return (
<Chart
basicInfo={{ title: t('analysis.totalConversations.title', { ns: 'appOverview' }), explanation: t('analysis.totalConversations.explanation', { ns: 'appOverview' }), timePeriod: period.name }}
chartData={!noDataFlag ? response : { data: getDefaultChartData(period.query ?? defaultPeriod) } as any}
chartType="conversations"
{...(noDataFlag && { yMax: 500 })}
/>
)
}
export const EndUsersChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation()
const { data: response, isLoading } = useAppDailyEndUsers(id, period.query)
if (isLoading || !response)
return <Loading />
const noDataFlag = !response.data || response.data.length === 0
return (
<Chart
basicInfo={{ title: t('analysis.activeUsers.title', { ns: 'appOverview' }), explanation: t('analysis.activeUsers.explanation', { ns: 'appOverview' }), timePeriod: period.name }}
chartData={!noDataFlag ? response : { data: getDefaultChartData(period.query ?? defaultPeriod) } as any}
chartType="endUsers"
{...(noDataFlag && { yMax: 500 })}
/>
)
}
export const AvgSessionInteractions: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation()
const { data: response, isLoading } = useAppAverageSessionInteractions(id, period.query)
if (isLoading || !response)
return <Loading />
const noDataFlag = !response.data || response.data.length === 0
return (
<Chart
basicInfo={{ title: t('analysis.avgSessionInteractions.title', { ns: 'appOverview' }), explanation: t('analysis.avgSessionInteractions.explanation', { ns: 'appOverview' }), timePeriod: period.name }}
chartData={!noDataFlag ? response : { data: getDefaultChartData({ ...(period.query ?? defaultPeriod), key: 'interactions' }) } as any}
chartType="conversations"
valueKey="interactions"
isAvg
{...(noDataFlag && { yMax: 500 })}
/>
)
}
export const AvgResponseTime: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation()
const { data: response, isLoading } = useAppAverageResponseTime(id, period.query)
if (isLoading || !response)
return <Loading />
const noDataFlag = !response.data || response.data.length === 0
return (
<Chart
basicInfo={{ title: t('analysis.avgResponseTime.title', { ns: 'appOverview' }), explanation: t('analysis.avgResponseTime.explanation', { ns: 'appOverview' }), timePeriod: period.name }}
chartData={!noDataFlag ? response : { data: getDefaultChartData({ ...(period.query ?? defaultPeriod), key: 'latency' }) } as any}
valueKey="latency"
chartType="conversations"
isAvg
unit={t('analysis.ms', { ns: 'appOverview' }) as string}
{...(noDataFlag && { yMax: 500 })}
/>
)
}
export const TokenPerSecond: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation()
const { data: response, isLoading } = useAppTokensPerSecond(id, period.query)
if (isLoading || !response)
return <Loading />
const noDataFlag = !response.data || response.data.length === 0
return (
<Chart
basicInfo={{ title: t('analysis.tps.title', { ns: 'appOverview' }), explanation: t('analysis.tps.explanation', { ns: 'appOverview' }), timePeriod: period.name }}
chartData={!noDataFlag ? response : { data: getDefaultChartData({ ...(period.query ?? defaultPeriod), key: 'tps' }) } as any}
valueKey="tps"
chartType="conversations"
isAvg
unit={t('analysis.tokenPS', { ns: 'appOverview' }) as string}
{...(noDataFlag && { yMax: 100 })}
className="min-w-0"
/>
)
}
export const UserSatisfactionRate: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation()
const { data: response, isLoading } = useAppSatisfactionRate(id, period.query)
if (isLoading || !response)
return <Loading />
const noDataFlag = !response.data || response.data.length === 0
return (
<Chart
basicInfo={{ title: t('analysis.userSatisfactionRate.title', { ns: 'appOverview' }), explanation: t('analysis.userSatisfactionRate.explanation', { ns: 'appOverview' }), timePeriod: period.name }}
chartData={!noDataFlag ? response : { data: getDefaultChartData({ ...(period.query ?? defaultPeriod), key: 'rate' }) } as any}
valueKey="rate"
chartType="endUsers"
isAvg
{...(noDataFlag && { yMax: 1000 })}
className="h-full"
/>
)
}
export const CostChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation()
const { data: response, isLoading } = useAppTokenCosts(id, period.query)
if (isLoading || !response)
return <Loading />
const noDataFlag = !response.data || response.data.length === 0
return (
<Chart
basicInfo={{ title: t('analysis.tokenUsage.title', { ns: 'appOverview' }), explanation: t('analysis.tokenUsage.explanation', { ns: 'appOverview' }), timePeriod: period.name }}
chartData={!noDataFlag ? response : { data: getDefaultChartData(period.query ?? defaultPeriod) } as any}
chartType="costs"
{...(noDataFlag && { yMax: 100 })}
/>
)
}
export const WorkflowMessagesChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation()
const { data: response, isLoading } = useWorkflowDailyConversations(id, period.query)
if (isLoading || !response)
return <Loading />
const noDataFlag = !response.data || response.data.length === 0
return (
<Chart
basicInfo={{ title: t('analysis.totalMessages.title', { ns: 'appOverview' }), explanation: t('analysis.totalMessages.explanation', { ns: 'appOverview' }), timePeriod: period.name }}
chartData={!noDataFlag ? response : { data: getDefaultChartData({ ...(period.query ?? defaultPeriod), key: 'runs' }) } as any}
chartType="conversations"
valueKey="runs"
{...(noDataFlag && { yMax: 500 })}
/>
)
}
export const WorkflowDailyTerminalsChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation()
const { data: response, isLoading } = useWorkflowDailyTerminals(id, period.query)
if (isLoading || !response)
return <Loading />
const noDataFlag = !response.data || response.data.length === 0
return (
<Chart
basicInfo={{ title: t('analysis.activeUsers.title', { ns: 'appOverview' }), explanation: t('analysis.activeUsers.explanation', { ns: 'appOverview' }), timePeriod: period.name }}
chartData={!noDataFlag ? response : { data: getDefaultChartData(period.query ?? defaultPeriod) } as any}
chartType="endUsers"
{...(noDataFlag && { yMax: 500 })}
/>
)
}
export const WorkflowCostChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation()
const { data: response, isLoading } = useWorkflowTokenCosts(id, period.query)
if (isLoading || !response)
return <Loading />
const noDataFlag = !response.data || response.data.length === 0
return (
<Chart
basicInfo={{ title: t('analysis.tokenUsage.title', { ns: 'appOverview' }), explanation: t('analysis.tokenUsage.explanation', { ns: 'appOverview' }), timePeriod: period.name }}
chartData={!noDataFlag ? response : { data: getDefaultChartData(period.query ?? defaultPeriod) } as any}
chartType="workflowCosts"
{...(noDataFlag && { yMax: 100 })}
/>
)
}
export const AvgUserInteractions: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation()
const { data: response, isLoading } = useWorkflowAverageInteractions(id, period.query)
if (isLoading || !response)
return <Loading />
const noDataFlag = !response.data || response.data.length === 0
return (
<Chart
basicInfo={{ title: t('analysis.avgUserInteractions.title', { ns: 'appOverview' }), explanation: t('analysis.avgUserInteractions.explanation', { ns: 'appOverview' }), timePeriod: period.name }}
chartData={!noDataFlag ? response : { data: getDefaultChartData({ ...(period.query ?? defaultPeriod), key: 'interactions' }) } as any}
chartType="conversations"
valueKey="interactions"
isAvg
{...(noDataFlag && { yMax: 500 })}
/>
)
}
export default Chart

View File

@@ -0,0 +1,178 @@
import type { EChartsOption } from 'echarts'
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import Chart from '../core'
import {
AvgResponseTime,
AvgSessionInteractions,
AvgUserInteractions,
ConversationsChart,
CostChart,
EndUsersChart,
MessagesChart,
TokenPerSecond,
UserSatisfactionRate,
WorkflowCostChart,
WorkflowDailyTerminalsChart,
WorkflowMessagesChart,
} from '../metrics'
const mockUseAppDailyMessages = vi.fn()
const mockUseAppDailyConversations = vi.fn()
const mockUseAppDailyEndUsers = vi.fn()
const mockUseAppAverageSessionInteractions = vi.fn()
const mockUseAppAverageResponseTime = vi.fn()
const mockUseAppTokensPerSecond = vi.fn()
const mockUseAppSatisfactionRate = vi.fn()
const mockUseAppTokenCosts = vi.fn()
const mockUseWorkflowDailyConversations = vi.fn()
const mockUseWorkflowDailyTerminals = vi.fn()
const mockUseWorkflowTokenCosts = vi.fn()
const mockUseWorkflowAverageInteractions = vi.fn()
let latestOption: EChartsOption | undefined
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
vi.mock('echarts-for-react', () => ({
default: ({ option }: { option: EChartsOption }) => {
latestOption = option
return <div data-testid="echart" />
},
}))
vi.mock('@/app/components/app-sidebar/basic', () => ({
default: ({ name, type, hoverTip, isExtraInLine }: {
name: ReactNode
type: ReactNode
hoverTip?: string
isExtraInLine?: boolean
}) => (
<div data-testid="basic">
<div>{name}</div>
<div>{type}</div>
{hoverTip && <div>{hoverTip}</div>}
{isExtraInLine && <div>inline-extra</div>}
</div>
),
}))
vi.mock('@/service/use-apps', () => ({
useAppDailyMessages: (...args: unknown[]) => mockUseAppDailyMessages(...args),
useAppDailyConversations: (...args: unknown[]) => mockUseAppDailyConversations(...args),
useAppDailyEndUsers: (...args: unknown[]) => mockUseAppDailyEndUsers(...args),
useAppAverageSessionInteractions: (...args: unknown[]) => mockUseAppAverageSessionInteractions(...args),
useAppAverageResponseTime: (...args: unknown[]) => mockUseAppAverageResponseTime(...args),
useAppTokensPerSecond: (...args: unknown[]) => mockUseAppTokensPerSecond(...args),
useAppSatisfactionRate: (...args: unknown[]) => mockUseAppSatisfactionRate(...args),
useAppTokenCosts: (...args: unknown[]) => mockUseAppTokenCosts(...args),
useWorkflowDailyConversations: (...args: unknown[]) => mockUseWorkflowDailyConversations(...args),
useWorkflowDailyTerminals: (...args: unknown[]) => mockUseWorkflowDailyTerminals(...args),
useWorkflowTokenCosts: (...args: unknown[]) => mockUseWorkflowTokenCosts(...args),
useWorkflowAverageInteractions: (...args: unknown[]) => mockUseWorkflowAverageInteractions(...args),
}))
const defaultPeriod = {
name: 'Last 7 days',
query: {
start: '2026-03-01',
end: '2026-03-08',
},
}
const buildResult = (data: Array<Record<string, string | number>>) => ({
data: { data },
isLoading: false,
})
const setDefaultHookResponses = () => {
mockUseAppDailyMessages.mockReturnValue(buildResult([{ date: '2026-03-01', message_count: 5 }]))
mockUseAppDailyConversations.mockReturnValue(buildResult([{ date: '2026-03-01', conversation_count: 3 }]))
mockUseAppDailyEndUsers.mockReturnValue(buildResult([{ date: '2026-03-01', terminal_count: 2 }]))
mockUseAppAverageSessionInteractions.mockReturnValue(buildResult([{ date: '2026-03-01', interactions: 4 }]))
mockUseAppAverageResponseTime.mockReturnValue(buildResult([{ date: '2026-03-01', latency: 120 }]))
mockUseAppTokensPerSecond.mockReturnValue(buildResult([{ date: '2026-03-01', tps: 8 }]))
mockUseAppSatisfactionRate.mockReturnValue(buildResult([{ date: '2026-03-01', rate: 95 }]))
mockUseAppTokenCosts.mockReturnValue(buildResult([{ date: '2026-03-01', token_count: 1200, total_price: 1.2345, currency: 1 }]))
mockUseWorkflowDailyConversations.mockReturnValue(buildResult([{ date: '2026-03-01', runs: 7 }]))
mockUseWorkflowDailyTerminals.mockReturnValue(buildResult([{ date: '2026-03-01', terminal_count: 6 }]))
mockUseWorkflowTokenCosts.mockReturnValue(buildResult([{ date: '2026-03-01', token_count: 900, total_price: 0.5678, currency: 1 }]))
mockUseWorkflowAverageInteractions.mockReturnValue(buildResult([{ date: '2026-03-01', interactions: 9 }]))
}
describe('app-chart', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOption = undefined
setDefaultHookResponses()
})
it('renders direct chart totals and cost labels', () => {
render(
<Chart
basicInfo={{
title: 'Token usage',
explanation: 'Cost explanation',
timePeriod: 'Last 7 days',
}}
chartType="costs"
chartData={{
data: [{ date: '2026-03-01', token_count: 1200, total_price: 1.2345, currency: 1 }],
}}
/>,
)
expect(screen.getAllByTestId('basic')[0]).toHaveTextContent('Token usage')
expect(screen.getAllByTestId('basic')[1]).toHaveTextContent('1k')
expect(screen.getAllByTestId('basic')[1]).toHaveTextContent('appOverview.analysis.tokenUsage.consumed')
expect(latestOption).toMatchObject({
dataset: {
dimensions: ['date', 'token_count'],
},
})
})
it('shows loading state while a wrapper hook is pending', () => {
mockUseAppDailyMessages.mockReturnValue({ data: undefined, isLoading: true })
render(<MessagesChart id="app-1" period={defaultPeriod} />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('falls back to empty chart data when the response has no rows', () => {
mockUseAppAverageResponseTime.mockReturnValue({ data: { data: [] }, isLoading: false })
render(<AvgResponseTime id="app-1" period={defaultPeriod} />)
expect(screen.getAllByTestId('basic')[1]).toHaveTextContent('0 appOverview.analysis.ms')
expect(latestOption).toMatchObject({
yAxis: {
max: 500,
},
})
})
it.each([
['messages', MessagesChart],
['conversations', ConversationsChart],
['end-users', EndUsersChart],
['avg-session-interactions', AvgSessionInteractions],
['avg-response-time', AvgResponseTime],
['tokens-per-second', TokenPerSecond],
['user-satisfaction', UserSatisfactionRate],
['costs', CostChart],
['workflow-messages', WorkflowMessagesChart],
['workflow-terminals', WorkflowDailyTerminalsChart],
['workflow-costs', WorkflowCostChart],
['avg-user-interactions', AvgUserInteractions],
])('renders the %s chart wrapper', (_label, Component) => {
render(<Component id="app-1" period={defaultPeriod} />)
expect(screen.getByTestId('echart')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,154 @@
import {
buildChartOptions,
formatChartSummaryValue,
getChartSummaryValue,
getChartValueKey,
getChartValues,
getDefaultChartData,
getStatisticNumber,
getTotalPriceValue,
isChartDataEmpty,
} from '../utils'
describe('app-chart-utils', () => {
it('should build default chart data for the provided range and key', () => {
expect(getDefaultChartData({
start: '2026-03-01',
end: '2026-03-03',
key: 'runs',
})).toEqual([
{ date: 'Mar 1, 2026', runs: 0 },
{ date: 'Mar 2, 2026', runs: 0 },
])
})
it('should keep a single zero-value point when start and end are the same day', () => {
expect(getDefaultChartData({
start: '2026-03-01',
end: '2026-03-01',
})).toEqual([
{ date: 'Mar 1, 2026', count: 0 },
])
})
it('should resolve chart keys and summary values', () => {
const statistics = [
{ date: 'Mar 1, 2026', message_count: 2, total_price: 1.2 },
{ date: 'Mar 2, 2026', message_count: 4, total_price: 2.3 },
]
expect(getChartValueKey(statistics, undefined)).toBe('message_count')
expect(getChartSummaryValue(statistics, 'message_count', false)).toBe(6)
expect(getChartSummaryValue(statistics, 'message_count', true)).toBe(3)
expect(getTotalPriceValue(statistics)).toBe(3.5)
})
it('should parse numeric and invalid statistic values safely', () => {
const statistic = {
date: 'Mar 1, 2026',
numeric: 4,
decimalString: '3.5',
invalidString: 'n/a',
}
expect(getStatisticNumber(statistic, 'numeric')).toBe(4)
expect(getStatisticNumber(statistic, 'decimalString')).toBe(3.5)
expect(getStatisticNumber(statistic, 'invalidString')).toBe(0)
expect(getStatisticNumber(statistic, 'missing')).toBe(0)
expect(getChartValues([statistic], 'decimalString')).toEqual([3.5])
})
it('should fall back to empty states when statistics are missing', () => {
expect(getChartValueKey([], undefined)).toBe('count')
expect(getChartSummaryValue([], 'message_count')).toBe(0)
expect(isChartDataEmpty()).toBe(true)
expect(isChartDataEmpty({ data: [] })).toBe(true)
expect(isChartDataEmpty({ data: [{ date: 'Mar 1, 2026', count: 1 }] })).toBe(false)
})
it('should format summary values for cost and non-cost charts', () => {
expect(formatChartSummaryValue({
chartType: 'messages',
summaryValue: 1200,
unit: 'ms',
})).toBe('1,200 ms')
expect(formatChartSummaryValue({
chartType: 'costs',
summaryValue: 999,
})).toBe('999')
expect(formatChartSummaryValue({
chartType: 'costs',
summaryValue: 1500,
})).toBe('2k')
})
it('should build chart options with token tooltip support', () => {
const options = buildChartOptions({
chartType: 'costs',
statistics: [
{ date: '2026-03-01', token_count: 1200, total_price: 1.2345 },
],
valueKey: 'token_count',
yMax: 100,
})
expect(options.dataset).toEqual({
dimensions: ['date', 'token_count'],
source: [{ date: '2026-03-01', token_count: 1200, total_price: 1.2345 }],
})
expect(options.yAxis).toMatchObject({ max: 100, type: 'value' })
const series = Array.isArray(options.series) ? options.series[0] : undefined
expect(series).toMatchObject({
type: 'line',
symbolSize: 4,
})
const formatter = typeof series === 'object' && series && 'tooltip' in series
? (series.tooltip as { formatter?: (params: { name: string, data: Record<string, string | number> }) => string }).formatter
: undefined
expect(formatter?.({
name: '2026-03-01',
data: {
date: '2026-03-01',
token_count: 1200,
total_price: 1.2345,
},
})).toContain('~$1.2345')
})
it('should expose axis formatters and split-line intervals for the chart grid', () => {
const options = buildChartOptions({
chartType: 'messages',
statistics: [
{ date: '2026-03-01', message_count: 1 },
{ date: '2026-03-02', message_count: 2 },
{ date: '2026-03-03', message_count: 3 },
],
valueKey: 'message_count',
})
const xAxes = Array.isArray(options.xAxis) ? options.xAxis : []
const primaryAxis = xAxes[0]
const secondaryAxis = xAxes[1]
const primaryLabelFormatter = typeof primaryAxis === 'object'
? primaryAxis.axisLabel?.formatter as ((value: string) => string) | undefined
: undefined
const primaryInterval = typeof primaryAxis === 'object'
? primaryAxis.splitLine?.interval as ((index: number) => boolean) | undefined
: undefined
const secondaryInterval = typeof secondaryAxis === 'object'
? secondaryAxis.splitLine?.interval as ((index: number, value: string) => boolean) | undefined
: undefined
expect(primaryLabelFormatter?.('2026-03-01')).toBe('Mar 1, 2026')
expect(primaryInterval?.(0)).toBe(true)
expect(primaryInterval?.(1)).toBe(false)
expect(primaryInterval?.(2)).toBe(true)
expect(secondaryInterval?.(0, '1')).toBe(true)
expect(secondaryInterval?.(0, '')).toBe(false)
})
})

View File

@@ -0,0 +1,98 @@
'use client'
import type { ReactNode } from 'react'
import type { ChartDataResponse, ChartType } from './utils'
import type { AppDailyConversationsResponse, AppDailyEndUsersResponse, AppDailyMessagesResponse, AppStatisticsResponse, AppTokenCostsResponse, WorkflowDailyConversationsResponse } from '@/models/app'
import ReactECharts from 'echarts-for-react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Basic from '@/app/components/app-sidebar/basic'
import {
buildChartOptions,
CHART_TYPE_CONFIG,
formatChartSummaryValue,
getChartSummaryValue,
getChartValueKey,
getTotalPriceValue,
} from './utils'
type ChartResponse
= | AppDailyMessagesResponse
| AppDailyConversationsResponse
| AppDailyEndUsersResponse
| AppTokenCostsResponse
| AppStatisticsResponse
| WorkflowDailyConversationsResponse
| ChartDataResponse
export type IChartProps = {
className?: string
basicInfo: {
title: string
explanation: string
timePeriod: string
}
valueKey?: string
isAvg?: boolean
unit?: string
yMax?: number
chartType: ChartType
chartData: ChartResponse
}
const buildTokenSummary = (label: string, tokenPriceSummary: string): ReactNode => (
<span>
{label}
{' '}
Tokens
<span className="text-sm">
<span className="ml-1 text-text-tertiary">(</span>
<span className="text-orange-400">
~
{tokenPriceSummary}
</span>
<span className="text-text-tertiary">)</span>
</span>
</span>
)
const Chart: React.FC<IChartProps> = ({
basicInfo: { title, explanation, timePeriod },
chartType = 'conversations',
chartData,
valueKey,
isAvg,
unit = '',
yMax,
className,
}) => {
const { t } = useTranslation()
const statistics = chartData.data
const resolvedValueKey = getChartValueKey(statistics, valueKey)
const summaryValue = getChartSummaryValue(statistics, resolvedValueKey, isAvg)
const showTokens = CHART_TYPE_CONFIG[chartType].showTokens
const tokenPriceSummary = getTotalPriceValue(statistics).toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 4,
})
return (
<div className={`flex w-full flex-col rounded-xl bg-components-chart-bg px-6 py-4 shadow-xs ${className ?? ''}`}>
<div className="mb-3">
<Basic name={title} type={timePeriod} hoverTip={explanation} />
</div>
<div className="mb-4 flex-1">
<Basic
isExtraInLine={showTokens}
name={formatChartSummaryValue({ chartType, summaryValue, unit })}
type={showTokens ? buildTokenSummary(t('analysis.tokenUsage.consumed', { ns: 'appOverview' }), tokenPriceSummary) : ''}
textStyle={{ main: `!text-3xl !font-normal ${summaryValue === 0 ? '!text-text-quaternary' : ''}` }}
/>
</div>
<ReactECharts option={buildChartOptions({ chartType, statistics, valueKey: resolvedValueKey, yMax })} style={{ height: 160 }} />
</div>
)
}
export default Chart

View File

@@ -0,0 +1,287 @@
'use client'
import type { TFunction } from 'i18next'
import type { FC } from 'react'
import type { IBizChartProps, PeriodParams } from './types'
import type { ChartDataResponse, ChartType } from './utils'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import {
useAppAverageResponseTime,
useAppAverageSessionInteractions,
useAppDailyConversations,
useAppDailyEndUsers,
useAppDailyMessages,
useAppSatisfactionRate,
useAppTokenCosts,
useAppTokensPerSecond,
useWorkflowAverageInteractions,
useWorkflowDailyConversations,
useWorkflowDailyTerminals,
useWorkflowTokenCosts,
} from '@/service/use-apps'
import Chart from './core'
import { defaultPeriod, getDefaultChartData, isChartDataEmpty } from './utils'
type MetricChartConfig = {
displayName: string
titleKey: string
explanationKey: string
chartType: ChartType
valueKey?: string
fallbackKey?: string
isAvg?: boolean
emptyYMax: number
className?: string
getUnit?: (translate: TFunction) => string
}
type MetricHook<Response extends ChartDataResponse> = (appId: string, params?: PeriodParams['query']) => {
data?: Response
isLoading: boolean
}
type MetricChartRendererProps = IBizChartProps & MetricChartConfig & {
response?: ChartDataResponse
isLoading: boolean
}
type MetricChartContainerProps<Response extends ChartDataResponse> = IBizChartProps & {
useMetricHook: MetricHook<Response>
config: MetricChartConfig
}
const translateMetricText = (translate: TFunction, key: string) => {
return translate(key, key, { ns: 'appOverview' }) as string
}
const MetricChartRenderer: FC<MetricChartRendererProps> = ({
period,
response,
isLoading,
titleKey,
explanationKey,
chartType,
valueKey,
fallbackKey,
isAvg,
emptyYMax,
className,
getUnit,
}) => {
const { t } = useTranslation()
if (isLoading || !response)
return <Loading />
const noData = isChartDataEmpty(response)
const chartData = noData
? {
data: getDefaultChartData({
...(period.query ?? defaultPeriod),
key: fallbackKey ?? valueKey ?? 'count',
}),
}
: response
return (
<Chart
basicInfo={{
title: translateMetricText(t, titleKey),
explanation: translateMetricText(t, explanationKey),
timePeriod: period.name,
}}
chartData={chartData}
chartType={chartType}
valueKey={valueKey}
isAvg={isAvg}
unit={getUnit?.(t) ?? ''}
className={className}
{...(noData && { yMax: emptyYMax })}
/>
)
}
const MetricChartContainer = <Response extends ChartDataResponse>({
id,
period,
useMetricHook,
config,
}: MetricChartContainerProps<Response>) => {
const { data, isLoading } = useMetricHook(id, period.query)
return (
<MetricChartRenderer
id={id}
period={period}
response={data}
isLoading={isLoading}
{...config}
/>
)
}
const MESSAGES_CHART_CONFIG: MetricChartConfig = {
displayName: 'MessagesChart',
titleKey: 'analysis.totalMessages.title',
explanationKey: 'analysis.totalMessages.explanation',
chartType: 'messages',
emptyYMax: 500,
}
const CONVERSATIONS_CHART_CONFIG: MetricChartConfig = {
displayName: 'ConversationsChart',
titleKey: 'analysis.totalConversations.title',
explanationKey: 'analysis.totalConversations.explanation',
chartType: 'conversations',
emptyYMax: 500,
}
const END_USERS_CHART_CONFIG: MetricChartConfig = {
displayName: 'EndUsersChart',
titleKey: 'analysis.activeUsers.title',
explanationKey: 'analysis.activeUsers.explanation',
chartType: 'endUsers',
emptyYMax: 500,
}
const AVG_SESSION_INTERACTIONS_CONFIG: MetricChartConfig = {
displayName: 'AvgSessionInteractions',
titleKey: 'analysis.avgSessionInteractions.title',
explanationKey: 'analysis.avgSessionInteractions.explanation',
chartType: 'conversations',
valueKey: 'interactions',
fallbackKey: 'interactions',
isAvg: true,
emptyYMax: 500,
}
const AVG_RESPONSE_TIME_CONFIG: MetricChartConfig = {
displayName: 'AvgResponseTime',
titleKey: 'analysis.avgResponseTime.title',
explanationKey: 'analysis.avgResponseTime.explanation',
chartType: 'conversations',
valueKey: 'latency',
fallbackKey: 'latency',
isAvg: true,
emptyYMax: 500,
getUnit: translate => translate('analysis.ms', { ns: 'appOverview' }) as string,
}
const TOKEN_PER_SECOND_CONFIG: MetricChartConfig = {
displayName: 'TokenPerSecond',
titleKey: 'analysis.tps.title',
explanationKey: 'analysis.tps.explanation',
chartType: 'conversations',
valueKey: 'tps',
fallbackKey: 'tps',
isAvg: true,
emptyYMax: 100,
className: 'min-w-0',
getUnit: translate => translate('analysis.tokenPS', { ns: 'appOverview' }) as string,
}
const USER_SATISFACTION_RATE_CONFIG: MetricChartConfig = {
displayName: 'UserSatisfactionRate',
titleKey: 'analysis.userSatisfactionRate.title',
explanationKey: 'analysis.userSatisfactionRate.explanation',
chartType: 'endUsers',
valueKey: 'rate',
fallbackKey: 'rate',
isAvg: true,
emptyYMax: 1000,
className: 'h-full',
}
const COST_CHART_CONFIG: MetricChartConfig = {
displayName: 'CostChart',
titleKey: 'analysis.tokenUsage.title',
explanationKey: 'analysis.tokenUsage.explanation',
chartType: 'costs',
emptyYMax: 100,
}
const WORKFLOW_MESSAGES_CHART_CONFIG: MetricChartConfig = {
displayName: 'WorkflowMessagesChart',
titleKey: 'analysis.totalMessages.title',
explanationKey: 'analysis.totalMessages.explanation',
chartType: 'conversations',
valueKey: 'runs',
fallbackKey: 'runs',
emptyYMax: 500,
}
const WORKFLOW_DAILY_TERMINALS_CHART_CONFIG: MetricChartConfig = {
displayName: 'WorkflowDailyTerminalsChart',
titleKey: 'analysis.activeUsers.title',
explanationKey: 'analysis.activeUsers.explanation',
chartType: 'endUsers',
emptyYMax: 500,
}
const WORKFLOW_COST_CHART_CONFIG: MetricChartConfig = {
displayName: 'WorkflowCostChart',
titleKey: 'analysis.tokenUsage.title',
explanationKey: 'analysis.tokenUsage.explanation',
chartType: 'workflowCosts',
emptyYMax: 100,
}
const AVG_USER_INTERACTIONS_CONFIG: MetricChartConfig = {
displayName: 'AvgUserInteractions',
titleKey: 'analysis.avgUserInteractions.title',
explanationKey: 'analysis.avgUserInteractions.explanation',
chartType: 'conversations',
valueKey: 'interactions',
fallbackKey: 'interactions',
isAvg: true,
emptyYMax: 500,
}
export const MessagesChart: FC<IBizChartProps> = props => (
<MetricChartContainer {...props} useMetricHook={useAppDailyMessages} config={MESSAGES_CHART_CONFIG} />
)
export const ConversationsChart: FC<IBizChartProps> = props => (
<MetricChartContainer {...props} useMetricHook={useAppDailyConversations} config={CONVERSATIONS_CHART_CONFIG} />
)
export const EndUsersChart: FC<IBizChartProps> = props => (
<MetricChartContainer {...props} useMetricHook={useAppDailyEndUsers} config={END_USERS_CHART_CONFIG} />
)
export const AvgSessionInteractions: FC<IBizChartProps> = props => (
<MetricChartContainer {...props} useMetricHook={useAppAverageSessionInteractions} config={AVG_SESSION_INTERACTIONS_CONFIG} />
)
export const AvgResponseTime: FC<IBizChartProps> = props => (
<MetricChartContainer {...props} useMetricHook={useAppAverageResponseTime} config={AVG_RESPONSE_TIME_CONFIG} />
)
export const TokenPerSecond: FC<IBizChartProps> = props => (
<MetricChartContainer {...props} useMetricHook={useAppTokensPerSecond} config={TOKEN_PER_SECOND_CONFIG} />
)
export const UserSatisfactionRate: FC<IBizChartProps> = props => (
<MetricChartContainer {...props} useMetricHook={useAppSatisfactionRate} config={USER_SATISFACTION_RATE_CONFIG} />
)
export const CostChart: FC<IBizChartProps> = props => (
<MetricChartContainer {...props} useMetricHook={useAppTokenCosts} config={COST_CHART_CONFIG} />
)
export const WorkflowMessagesChart: FC<IBizChartProps> = props => (
<MetricChartContainer {...props} useMetricHook={useWorkflowDailyConversations} config={WORKFLOW_MESSAGES_CHART_CONFIG} />
)
export const WorkflowDailyTerminalsChart: FC<IBizChartProps> = props => (
<MetricChartContainer {...props} useMetricHook={useWorkflowDailyTerminals} config={WORKFLOW_DAILY_TERMINALS_CHART_CONFIG} />
)
export const WorkflowCostChart: FC<IBizChartProps> = props => (
<MetricChartContainer {...props} useMetricHook={useWorkflowTokenCosts} config={WORKFLOW_COST_CHART_CONFIG} />
)
export const AvgUserInteractions: FC<IBizChartProps> = props => (
<MetricChartContainer {...props} useMetricHook={useWorkflowAverageInteractions} config={AVG_USER_INTERACTIONS_CONFIG} />
)

View File

@@ -0,0 +1,24 @@
import type { Dayjs } from 'dayjs'
export type PeriodParams = {
name: string
query?: {
start: string
end: string
}
}
export type TimeRange = {
start: Dayjs
end: Dayjs
}
export type PeriodParamsWithTimeRange = {
name: string
query?: TimeRange
}
export type IBizChartProps = {
period: PeriodParams
id: string
}

View File

@@ -0,0 +1,268 @@
import type { EChartsOption } from 'echarts'
import dayjs from 'dayjs'
import Decimal from 'decimal.js'
import { formatNumber } from '@/utils/format'
export type ChartStatisticRecord = {
date: string
} & Record<string, string | number | undefined>
export type ChartDataResponse = {
data: ChartStatisticRecord[]
}
type ColorType = 'green' | 'orange' | 'blue'
export type ChartType = 'messages' | 'conversations' | 'endUsers' | 'costs' | 'workflowCosts'
type ChartConfig = {
colorType: ColorType
showTokens?: boolean
}
export const commonDateFormat = 'MMM D, YYYY'
export const defaultPeriod = {
start: dayjs().subtract(7, 'day').format(commonDateFormat),
end: dayjs().format(commonDateFormat),
}
const COLOR_TYPE_MAP: Record<ColorType, { lineColor: string, bgColor: [string, string] }> = {
green: {
lineColor: 'rgba(6, 148, 162, 1)',
bgColor: ['rgba(6, 148, 162, 0.2)', 'rgba(67, 174, 185, 0.08)'],
},
orange: {
lineColor: 'rgba(255, 138, 76, 1)',
bgColor: ['rgba(254, 145, 87, 0.2)', 'rgba(255, 138, 76, 0.1)'],
},
blue: {
lineColor: 'rgba(28, 100, 242, 1)',
bgColor: ['rgba(28, 100, 242, 0.3)', 'rgba(28, 100, 242, 0.1)'],
},
}
const COMMON_COLOR_MAP = {
label: '#9CA3AF',
splitLineLight: '#F3F4F6',
splitLineDark: '#E5E7EB',
}
export const CHART_TYPE_CONFIG: Record<ChartType, ChartConfig> = {
messages: {
colorType: 'green',
},
conversations: {
colorType: 'green',
},
endUsers: {
colorType: 'orange',
},
costs: {
colorType: 'blue',
showTokens: true,
},
workflowCosts: {
colorType: 'blue',
},
}
export const sum = (values: Decimal.Value[]): number => Decimal.sum(...values).toNumber()
export const getDefaultChartData = ({ start, end, key = 'count' }: { start: string, end: string, key?: string }): ChartStatisticRecord[] => {
const diffDays = dayjs(end).diff(dayjs(start), 'day')
return Array.from({ length: diffDays || 1 }, (_, index) => ({
date: dayjs(start).add(index, 'day').format(commonDateFormat),
[key]: 0,
}))
}
export const isChartDataEmpty = (chartData?: ChartDataResponse) => !chartData?.data?.length
export const getChartValueKey = (statistics: ChartStatisticRecord[], valueKey?: string) => {
if (valueKey)
return valueKey
return Object.keys(statistics[0] ?? {}).find(name => name.includes('count')) ?? 'count'
}
export const getStatisticNumber = (statistic: ChartStatisticRecord, key: string) => {
const value = statistic[key]
if (typeof value === 'number')
return value
if (typeof value === 'string') {
const parsed = Number.parseFloat(value)
return Number.isNaN(parsed) ? 0 : parsed
}
return 0
}
export const getChartValues = (statistics: ChartStatisticRecord[], valueKey: string) => {
return statistics.map(statistic => getStatisticNumber(statistic, valueKey))
}
export const getChartSummaryValue = (statistics: ChartStatisticRecord[], valueKey: string, isAvg?: boolean) => {
const values = getChartValues(statistics, valueKey)
if (!values.length)
return 0
const total = sum(values)
return isAvg ? total / values.length : total
}
export const getTotalPriceValue = (statistics: ChartStatisticRecord[]) => {
return sum(statistics.map(statistic => getStatisticNumber(statistic, 'total_price')))
}
export const formatChartSummaryValue = ({
chartType,
summaryValue,
unit = '',
}: {
chartType: ChartType
summaryValue: number
unit?: string
}) => {
if (chartType === 'costs')
return summaryValue < 1000 ? `${summaryValue}` : `${formatNumber(Math.round(summaryValue / 1000))}k`
return `${summaryValue.toLocaleString()}${unit ? ` ${unit}` : ''}`
}
export const buildChartOptions = ({
chartType,
statistics,
valueKey,
yMax,
}: {
chartType: ChartType
statistics: ChartStatisticRecord[]
valueKey: string
yMax?: number
}): EChartsOption => {
const colorConfig = COLOR_TYPE_MAP[CHART_TYPE_CONFIG[chartType].colorType]
const statisticsLength = statistics.length
const markLineLength = statisticsLength >= 2 ? statisticsLength - 2 : statisticsLength
const markLineLabels = Array.from({ length: markLineLength }, () => '1')
markLineLabels.push('')
markLineLabels.unshift('')
return {
dataset: {
dimensions: ['date', valueKey],
source: statistics,
},
grid: { top: 8, right: 36, bottom: 10, left: 25, containLabel: true },
tooltip: {
trigger: 'item',
position: 'top',
borderWidth: 0,
},
xAxis: [{
type: 'category',
boundaryGap: false,
axisLabel: {
color: COMMON_COLOR_MAP.label,
hideOverlap: true,
overflow: 'break',
formatter(value) {
return dayjs(value).format(commonDateFormat)
},
},
axisLine: { show: false },
axisTick: { show: false },
splitLine: {
show: true,
lineStyle: {
color: COMMON_COLOR_MAP.splitLineLight,
width: 1,
type: [10, 10],
},
interval(index) {
return index === 0 || index === statistics.length - 1
},
},
}, {
position: 'bottom',
boundaryGap: false,
data: markLineLabels,
axisLabel: { show: false },
axisLine: { show: false },
axisTick: { show: false },
splitLine: {
show: true,
lineStyle: {
color: COMMON_COLOR_MAP.splitLineDark,
},
interval(_index, value) {
return !!value
},
},
}],
yAxis: {
max: yMax ?? 'dataMax',
type: 'value',
axisLabel: { color: COMMON_COLOR_MAP.label, hideOverlap: true },
splitLine: {
lineStyle: {
color: COMMON_COLOR_MAP.splitLineLight,
},
},
},
series: [
{
type: 'line',
showSymbol: true,
symbolSize: 4,
lineStyle: {
color: colorConfig.lineColor,
width: 2,
},
itemStyle: {
color: colorConfig.lineColor,
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0,
color: colorConfig.bgColor[0],
}, {
offset: 1,
color: colorConfig.bgColor[1],
}],
global: false,
},
},
tooltip: {
padding: [8, 12, 8, 12],
formatter(params) {
const data = typeof params.data === 'object' && params.data && !Array.isArray(params.data)
? params.data as ChartStatisticRecord
: undefined
const value = data ? getStatisticNumber(data, valueKey) : 0
const totalPrice = data ? getStatisticNumber(data, 'total_price') : 0
return `<div style='color:#6B7280;font-size:12px'>${params.name}</div>
<div style='font-size:14px;color:#1F2A37'>${value}
${!CHART_TYPE_CONFIG[chartType].showTokens
? ''
: `<span style='font-size:12px'>
<span style='margin-left:4px;color:#6B7280'>(</span>
<span style='color:#FF8A4C'>~$${totalPrice}</span>
<span style='color:#6B7280'>)</span>
</span>`}
</div>`
},
},
},
],
}
}

View File

@@ -0,0 +1,61 @@
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { fetchTextGenerationMessage } from '@/service/debug'
export const MAX_GENERATION_ITEM_DEPTH = 3
export const getCurrentTab = (workflowProcessData?: WorkflowProcess) => {
if (
workflowProcessData?.resultText
|| !!workflowProcessData?.files?.length
|| !!workflowProcessData?.humanInputFormDataList?.length
|| !!workflowProcessData?.humanInputFilledFormDataList?.length
) {
return 'RESULT'
}
return 'DETAIL'
}
export const buildLogItem = ({
answer,
data,
messageId,
}: {
answer?: string
data: Awaited<ReturnType<typeof fetchTextGenerationMessage>>
messageId?: string | null
}): IChatItem => {
const assistantFiles = data.message_files?.filter(file => file.belongs_to === 'assistant') || []
const normalizedMessage = typeof data.message === 'string'
? { role: 'user', text: data.message }
: data.message
const baseLog = Array.isArray(normalizedMessage) ? normalizedMessage : [normalizedMessage]
const log = Array.isArray(normalizedMessage)
? [
...normalizedMessage,
...(normalizedMessage.length > 0 && normalizedMessage[normalizedMessage.length - 1].role !== 'assistant'
? [{
role: 'assistant',
text: answer || '',
files: assistantFiles,
}]
: []),
]
: baseLog
return {
id: data.id || messageId || '',
content: answer || '',
isAnswer: true,
log,
message_files: data.message_files,
}
}
export const getWorkflowTabSignature = (workflowProcessData?: WorkflowProcess) => JSON.stringify({
filesLength: workflowProcessData?.files?.length ?? 0,
humanInputFilledFormDataListLength: workflowProcessData?.humanInputFilledFormDataList?.length ?? 0,
humanInputFormDataListLength: workflowProcessData?.humanInputFormDataList?.length ?? 0,
resultText: workflowProcessData?.resultText ?? '',
})

View File

@@ -1,8 +1,7 @@
'use client'
import type { IGenerationItemProps } from './index'
import type { FeedbackType, IChatItem } from '@/app/components/base/chat/chat/type'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import { useBoolean } from 'ahooks'
import copy from 'copy-to-clipboard'
import { useCallback, useMemo, useState } from 'react'
@@ -19,57 +18,12 @@ import {
updateFeedback,
} from '@/service/share'
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
const MAX_DEPTH = 3
const getCurrentTab = (workflowProcessData?: WorkflowProcess) => {
if (
workflowProcessData?.resultText
|| !!workflowProcessData?.files?.length
|| !!workflowProcessData?.humanInputFormDataList?.length
|| !!workflowProcessData?.humanInputFilledFormDataList?.length
) {
return 'RESULT'
}
return 'DETAIL'
}
const buildLogItem = ({
answer,
data,
messageId,
}: {
answer?: string
data: Awaited<ReturnType<typeof fetchTextGenerationMessage>>
messageId?: string | null
}): IChatItem => {
const assistantFiles = data.message_files?.filter(file => file.belongs_to === 'assistant') || []
const normalizedMessage = typeof data.message === 'string'
? { role: 'user', text: data.message }
: data.message
const baseLog = Array.isArray(normalizedMessage) ? normalizedMessage : [normalizedMessage]
const log = Array.isArray(normalizedMessage)
? [
...normalizedMessage,
...(normalizedMessage.length > 0 && normalizedMessage[normalizedMessage.length - 1].role !== 'assistant'
? [{
role: 'assistant',
text: answer || '',
files: assistantFiles,
}]
: []),
]
: baseLog
return {
id: data.id || messageId || '',
content: answer || '',
isAnswer: true,
log,
message_files: data.message_files,
}
}
import {
buildLogItem,
getCurrentTab,
getWorkflowTabSignature,
MAX_GENERATION_ITEM_DEPTH,
} from './use-generation-item-utils'
type UseGenerationItemParams = Pick<
IGenerationItemProps,
@@ -108,13 +62,6 @@ type MoreLikeThisResponse = {
id?: string
}
const getWorkflowTabSignature = (workflowProcessData?: WorkflowProcess) => JSON.stringify({
filesLength: workflowProcessData?.files?.length ?? 0,
humanInputFilledFormDataListLength: workflowProcessData?.humanInputFilledFormDataList?.length ?? 0,
humanInputFormDataListLength: workflowProcessData?.humanInputFormDataList?.length ?? 0,
resultText: workflowProcessData?.resultText ?? '',
})
export const useGenerationItem = ({
appSourceType,
content,
@@ -297,7 +244,7 @@ export const useGenerationItem = ({
isTop,
isTryApp,
setCurrentTab,
showChildItem: (childMessageId || isQuerying) && depth < MAX_DEPTH,
showChildItem: (childMessageId || isQuerying) && depth < MAX_GENERATION_ITEM_DEPTH,
taskLabel,
}
}