mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 16:17:25 +08:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'),
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'),
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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' }))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
157
web/app/components/app/overview/app-card/index.tsx
Normal file
157
web/app/components/app/overview/app-card/index.tsx
Normal 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
|
||||
320
web/app/components/app/overview/app-card/sections.tsx
Normal file
320
web/app/components/app/overview/app-card/sections.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
web/app/components/app/overview/app-card/types.ts
Normal file
19
web/app/components/app/overview/app-card/types.ts
Normal 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>
|
||||
}
|
||||
264
web/app/components/app/overview/app-card/use-app-card.ts
Normal file
264
web/app/components/app/overview/app-card/use-app-card.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
178
web/app/components/app/overview/chart/__tests__/metrics.spec.tsx
Normal file
178
web/app/components/app/overview/chart/__tests__/metrics.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
154
web/app/components/app/overview/chart/__tests__/utils.spec.ts
Normal file
154
web/app/components/app/overview/chart/__tests__/utils.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
98
web/app/components/app/overview/chart/core.tsx
Normal file
98
web/app/components/app/overview/chart/core.tsx
Normal 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
|
||||
287
web/app/components/app/overview/chart/metrics.tsx
Normal file
287
web/app/components/app/overview/chart/metrics.tsx
Normal 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} />
|
||||
)
|
||||
24
web/app/components/app/overview/chart/types.ts
Normal file
24
web/app/components/app/overview/chart/types.ts
Normal 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
|
||||
}
|
||||
268
web/app/components/app/overview/chart/utils.ts
Normal file
268
web/app/components/app/overview/chart/utils.ts
Normal 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>`
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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 ?? '',
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user