mirror of
https://github.com/langgenius/dify.git
synced 2026-10-16 07:20:34 +08:00
refactor(web): migrate log queries to the generated console contract
Replace the forwarding hooks in service/use-log.ts with direct consoleQuery calls at each call site and type the log pages with the generated contract instead of handwritten DTOs. - Conversation list/detail, workflow logs, pause details and annotation count now use consoleQuery queryOptions; use-log.ts is removed. - Untyped JSON fields (model_config.model, user_input_form, details) are narrowed at the boundary in list-utils instead of being read as any. - Read the conversation introduction from the top-level field the API actually returns, and skip null backstage input URLs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c4a650480f
commit
8f6d92949a
@@ -474,7 +474,7 @@
|
||||
"count": 2
|
||||
},
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 5
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/app/log/var-panel.tsx": {
|
||||
@@ -4255,14 +4255,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/use-log.ts": {
|
||||
"@tanstack/query/prefer-query-options": {
|
||||
"count": 7
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/use-models.ts": {
|
||||
"@tanstack/query/prefer-query-options": {
|
||||
"count": 3
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
import type { AnnotationCountResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
import type { Mock } from 'vite-plus/test'
|
||||
import type { QueryParam } from '../filter'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import * as useLogModule from '@/service/use-log'
|
||||
import Filter from '../filter'
|
||||
|
||||
vi.mock('@/service/use-log')
|
||||
const mockUseQuery = vi.fn()
|
||||
|
||||
const mockUseAnnotationsCount = useLogModule.useAnnotationsCount as Mock
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@tanstack/react-query')>()),
|
||||
useQuery: (options: unknown) => mockUseQuery(options),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/console', () => ({
|
||||
consoleQuery: {
|
||||
apps: {
|
||||
byAppId: {
|
||||
annotations: {
|
||||
count: {
|
||||
get: {
|
||||
queryOptions: (options: unknown) => options,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Utilities
|
||||
@@ -65,7 +82,7 @@ describe('Filter', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render nothing when data is loading', () => {
|
||||
// Arrange
|
||||
mockUseAnnotationsCount.mockReturnValue(
|
||||
mockUseQuery.mockReturnValue(
|
||||
createMockQueryResult<AnnotationCountResponse>({ isLoading: true }),
|
||||
)
|
||||
|
||||
@@ -82,7 +99,7 @@ describe('Filter', () => {
|
||||
|
||||
it('should render nothing when data is undefined', () => {
|
||||
// Arrange
|
||||
mockUseAnnotationsCount.mockReturnValue(
|
||||
mockUseQuery.mockReturnValue(
|
||||
createMockQueryResult<AnnotationCountResponse>({ data: undefined, isLoading: false }),
|
||||
)
|
||||
|
||||
@@ -99,7 +116,7 @@ describe('Filter', () => {
|
||||
|
||||
it('should render filter and children when data is available', () => {
|
||||
// Arrange
|
||||
mockUseAnnotationsCount.mockReturnValue(
|
||||
mockUseQuery.mockReturnValue(
|
||||
createMockQueryResult<AnnotationCountResponse>({
|
||||
data: { count: 20 },
|
||||
isLoading: false,
|
||||
@@ -123,9 +140,9 @@ describe('Filter', () => {
|
||||
// Props Tests (REQUIRED)
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Props', () => {
|
||||
it('should call useAnnotationsCount with appId', () => {
|
||||
it('should query the annotations count for appId', () => {
|
||||
// Arrange
|
||||
mockUseAnnotationsCount.mockReturnValue(
|
||||
mockUseQuery.mockReturnValue(
|
||||
createMockQueryResult<AnnotationCountResponse>({
|
||||
data: { count: 10 },
|
||||
isLoading: false,
|
||||
@@ -140,12 +157,12 @@ describe('Filter', () => {
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(mockUseAnnotationsCount).toHaveBeenCalledWith(appId)
|
||||
expect(mockUseQuery).toHaveBeenCalledWith({ input: { params: { app_id: appId } } })
|
||||
})
|
||||
|
||||
it('should display keyword value in input', () => {
|
||||
// Arrange
|
||||
mockUseAnnotationsCount.mockReturnValue(
|
||||
mockUseQuery.mockReturnValue(
|
||||
createMockQueryResult<AnnotationCountResponse>({
|
||||
data: { count: 10 },
|
||||
isLoading: false,
|
||||
@@ -173,7 +190,7 @@ describe('Filter', () => {
|
||||
describe('User Interactions', () => {
|
||||
it('should call setQueryParams when typing in search input', () => {
|
||||
// Arrange
|
||||
mockUseAnnotationsCount.mockReturnValue(
|
||||
mockUseQuery.mockReturnValue(
|
||||
createMockQueryResult<AnnotationCountResponse>({
|
||||
data: { count: 20 },
|
||||
isLoading: false,
|
||||
@@ -198,7 +215,7 @@ describe('Filter', () => {
|
||||
|
||||
it('should call setQueryParams with empty keyword when clearing input', () => {
|
||||
// Arrange
|
||||
mockUseAnnotationsCount.mockReturnValue(
|
||||
mockUseQuery.mockReturnValue(
|
||||
createMockQueryResult<AnnotationCountResponse>({
|
||||
data: { count: 20 },
|
||||
isLoading: false,
|
||||
@@ -227,7 +244,7 @@ describe('Filter', () => {
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty keyword in queryParams', () => {
|
||||
// Arrange
|
||||
mockUseAnnotationsCount.mockReturnValue(
|
||||
mockUseQuery.mockReturnValue(
|
||||
createMockQueryResult<AnnotationCountResponse>({
|
||||
data: { count: 5 },
|
||||
isLoading: false,
|
||||
@@ -247,7 +264,7 @@ describe('Filter', () => {
|
||||
|
||||
it('should handle undefined keyword in queryParams', () => {
|
||||
// Arrange
|
||||
mockUseAnnotationsCount.mockReturnValue(
|
||||
mockUseQuery.mockReturnValue(
|
||||
createMockQueryResult<AnnotationCountResponse>({
|
||||
data: { count: 5 },
|
||||
isLoading: false,
|
||||
@@ -267,7 +284,7 @@ describe('Filter', () => {
|
||||
|
||||
it('should handle zero count', () => {
|
||||
// Arrange
|
||||
mockUseAnnotationsCount.mockReturnValue(
|
||||
mockUseQuery.mockReturnValue(
|
||||
createMockQueryResult<AnnotationCountResponse>({
|
||||
data: { count: 0 },
|
||||
isLoading: false,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import { useAnnotationsCount } from '@/service/use-log'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
|
||||
export type QueryParam = {
|
||||
keyword?: string
|
||||
@@ -17,7 +18,11 @@ type IFilterProps = {
|
||||
}
|
||||
|
||||
const Filter: FC<IFilterProps> = ({ appId, queryParams, setQueryParams, children }) => {
|
||||
const { data, isLoading } = useAnnotationsCount(appId)
|
||||
const { data, isLoading } = useQuery(
|
||||
consoleQuery.apps.byAppId.annotations.count.get.queryOptions({
|
||||
input: appId ? { params: { app_id: appId } } : skipToken,
|
||||
}),
|
||||
)
|
||||
const { t } = useTranslation(['common'])
|
||||
if (isLoading || !data) return null
|
||||
return (
|
||||
|
||||
@@ -27,19 +27,30 @@ const render = (ui: ReactElement) => {
|
||||
queryFn: () => new Promise(() => {}),
|
||||
})
|
||||
} else seedFeatures(queryClient, { billing: { subscription: { plan: scenario.planType } } })
|
||||
const annotationsCountQueryKey = consoleQuery.apps.byAppId.annotations.count.get.queryKey({
|
||||
input: { params: { app_id: 'test-app-id' } },
|
||||
})
|
||||
if (mockAnnotationsCountLoading) {
|
||||
void queryClient.query({
|
||||
queryKey: annotationsCountQueryKey,
|
||||
queryFn: () => new Promise(() => {}),
|
||||
})
|
||||
} else if (mockAnnotationsCountData) {
|
||||
queryClient.setQueryData(annotationsCountQueryKey, mockAnnotationsCountData)
|
||||
} else {
|
||||
void queryClient
|
||||
.query({
|
||||
queryKey: annotationsCountQueryKey,
|
||||
queryFn: () => Promise.reject(new Error('annotations count unavailable')),
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
return renderWithConsoleQuery(ui, {
|
||||
queryClient,
|
||||
systemFeatures: { deployment_edition: scenario.deploymentEdition },
|
||||
})
|
||||
}
|
||||
|
||||
vi.mock('@/service/use-log', () => ({
|
||||
useAnnotationsCount: () => ({
|
||||
data: mockAnnotationsCountData,
|
||||
isLoading: mockAnnotationsCountLoading,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chip', async () => {
|
||||
const { useState } = await import('react')
|
||||
|
||||
@@ -350,7 +361,7 @@ describe('Filter', () => {
|
||||
it('should display annotated status with count when annotation_status is annotated', () => {
|
||||
const propsWithAnnotation = {
|
||||
...defaultProps,
|
||||
queryParams: { ...defaultQueryParams, annotation_status: 'annotated' },
|
||||
queryParams: { ...defaultQueryParams, annotation_status: 'annotated' as const },
|
||||
}
|
||||
|
||||
render(<Filter {...propsWithAnnotation} />)
|
||||
@@ -363,7 +374,7 @@ describe('Filter', () => {
|
||||
it('should display not_annotated status when annotation_status is not_annotated', () => {
|
||||
const propsWithNotAnnotated = {
|
||||
...defaultProps,
|
||||
queryParams: { ...defaultQueryParams, annotation_status: 'not_annotated' },
|
||||
queryParams: { ...defaultQueryParams, annotation_status: 'not_annotated' as const },
|
||||
}
|
||||
|
||||
render(<Filter {...propsWithNotAnnotated} />)
|
||||
@@ -394,7 +405,7 @@ describe('Filter', () => {
|
||||
const propsWithSort = {
|
||||
...defaultProps,
|
||||
isChatMode: true,
|
||||
queryParams: { ...defaultQueryParams, sort_by: 'created_at' },
|
||||
queryParams: { ...defaultQueryParams, sort_by: 'created_at' as const },
|
||||
}
|
||||
|
||||
render(<Filter {...propsWithSort} />)
|
||||
@@ -408,7 +419,7 @@ describe('Filter', () => {
|
||||
const propsWithDescSort = {
|
||||
...defaultProps,
|
||||
isChatMode: true,
|
||||
queryParams: { ...defaultQueryParams, sort_by: '-created_at' },
|
||||
queryParams: { ...defaultQueryParams, sort_by: '-created_at' as const },
|
||||
}
|
||||
|
||||
render(<Filter {...propsWithDescSort} />)
|
||||
|
||||
@@ -35,13 +35,31 @@ vi.mock('@/next/navigation', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-log', () => ({
|
||||
useChatConversations: (...args: unknown[]) => mockUseChatConversations(...args),
|
||||
useCompletionConversations: (...args: unknown[]) => mockUseCompletionConversations(...args),
|
||||
useAnnotationsCount: () => ({
|
||||
data: { count: 0 },
|
||||
isLoading: false,
|
||||
}),
|
||||
vi.mock('@/service/console', () => {
|
||||
const tagged = (operation: string) => ({
|
||||
queryOptions: (options: Record<string, unknown>) => ({ ...options, operation }),
|
||||
})
|
||||
return {
|
||||
consoleQuery: {
|
||||
apps: {
|
||||
byAppId: {
|
||||
chatConversations: { get: tagged('chatConversations') },
|
||||
completionConversations: { get: tagged('completionConversations') },
|
||||
annotations: { count: { get: tagged('annotationsCount') } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@tanstack/react-query')>()),
|
||||
useQuery: ({ operation, ...options }: { operation?: string }) => {
|
||||
if (operation === 'chatConversations') return mockUseChatConversations(options)
|
||||
if (operation === 'completionConversations') return mockUseCompletionConversations(options)
|
||||
if (operation === 'annotationsCount') return { data: { count: 0 }, isLoading: false }
|
||||
return { data: undefined }
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../cloud-sandbox-retention', async (importOriginal) => {
|
||||
@@ -102,7 +120,7 @@ describe('Logs', () => {
|
||||
|
||||
expect(mockUseChatConversations).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
appId: 'app-1',
|
||||
input: expect.objectContaining({ params: { app_id: 'app-1' } }),
|
||||
}),
|
||||
)
|
||||
expect(screen.getByRole('heading', { name: /(?:^|\.)title(?=$|:)/ })).toBeInTheDocument()
|
||||
@@ -133,7 +151,7 @@ describe('Logs', () => {
|
||||
|
||||
expect(mockUseCompletionConversations).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
appId: 'app-2',
|
||||
input: expect.objectContaining({ params: { app_id: 'app-2' } }),
|
||||
}),
|
||||
)
|
||||
expect(screen.getByText('empty-logs')).toBeInTheDocument()
|
||||
@@ -188,9 +206,11 @@ describe('Logs', () => {
|
||||
).toBeInTheDocument()
|
||||
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
start: dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
end: dayjs().endOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
input: expect.objectContaining({
|
||||
query: expect.objectContaining({
|
||||
start: dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
end: dayjs().endOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@@ -213,9 +233,11 @@ describe('Logs', () => {
|
||||
await user.click(await screen.findByText(/appLog\.filter\.period\.allTime/))
|
||||
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
params: expect.not.objectContaining({
|
||||
start: expect.anything(),
|
||||
end: expect.anything(),
|
||||
input: expect.objectContaining({
|
||||
query: expect.not.objectContaining({
|
||||
start: expect.anything(),
|
||||
end: expect.anything(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@@ -234,9 +256,11 @@ describe('Logs', () => {
|
||||
).toBeInTheDocument()
|
||||
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
start: dayjs().startOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
end: expect.any(String),
|
||||
input: expect.objectContaining({
|
||||
query: expect.objectContaining({
|
||||
start: dayjs().startOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
end: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -35,15 +35,20 @@ vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-log', () => ({
|
||||
useChatConversationDetail: () => ({
|
||||
data: mockChatConversationDetail,
|
||||
}),
|
||||
useCompletionConversationDetail: () => ({
|
||||
data: mockCompletionConversationDetail,
|
||||
refetch: mockCompletionRefetch,
|
||||
}),
|
||||
}))
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useQuery: (options: Parameters<typeof actual.useQuery>[0]) => {
|
||||
const [path] = options.queryKey as [string[]]
|
||||
if (Array.isArray(path) && path.includes('byConversationId')) {
|
||||
if (path.includes('chatConversations')) return { data: mockChatConversationDetail }
|
||||
return { data: mockCompletionConversationDetail, refetch: mockCompletionRefetch }
|
||||
}
|
||||
return actual.useQuery(options)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/log', () => ({
|
||||
fetchChatMessages: (...args: unknown[]) => mockFetchChatMessages(...args),
|
||||
@@ -60,7 +65,9 @@ vi.mock('@/app/components/base/loading-placeholder', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/log/model-info', () => ({
|
||||
default: ({ model }: { model: string }) => <div data-testid="model-info">{model}</div>,
|
||||
default: ({ model }: { model: { name: string } }) => (
|
||||
<div data-testid="model-info">{model.name}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/log/var-panel', () => ({
|
||||
@@ -401,11 +408,9 @@ describe('ConversationList', () => {
|
||||
mockChatConversationDetail = {
|
||||
id: 'conversation-1',
|
||||
created_at: 1710000000,
|
||||
introduction: 'Hello there',
|
||||
model_config: {
|
||||
model: 'gpt-4o',
|
||||
configs: {
|
||||
introduction: 'Hello there',
|
||||
},
|
||||
model: { provider: 'openai', name: 'gpt-4o', mode: 'chat', completion_params: {} },
|
||||
user_input_form: [],
|
||||
},
|
||||
message: {
|
||||
@@ -443,11 +448,9 @@ describe('ConversationList', () => {
|
||||
mockChatConversationDetail = {
|
||||
id: 'conversation-1',
|
||||
created_at: 1710000000,
|
||||
introduction: 'Hello there',
|
||||
model_config: {
|
||||
model: 'gpt-4o',
|
||||
configs: {
|
||||
introduction: 'Hello there',
|
||||
},
|
||||
model: { provider: 'openai', name: 'gpt-4o', mode: 'chat', completion_params: {} },
|
||||
user_input_form: [
|
||||
{
|
||||
query: {
|
||||
@@ -525,11 +528,9 @@ describe('ConversationList', () => {
|
||||
mockChatConversationDetail = {
|
||||
id: 'conversation-1',
|
||||
created_at: 1710000000,
|
||||
introduction: 'Hello there',
|
||||
model_config: {
|
||||
model: 'gpt-4o',
|
||||
configs: {
|
||||
introduction: 'Hello there',
|
||||
},
|
||||
model: { provider: 'openai', name: 'gpt-4o', mode: 'chat', completion_params: {} },
|
||||
user_input_form: [],
|
||||
},
|
||||
message: {
|
||||
@@ -554,7 +555,7 @@ describe('ConversationList', () => {
|
||||
id: 'conversation-1',
|
||||
created_at: 1710000000,
|
||||
model_config: {
|
||||
model: 'gpt-4o-mini',
|
||||
model: { provider: 'openai', name: 'gpt-4o-mini', mode: 'chat', completion_params: {} },
|
||||
user_input_form: [
|
||||
{
|
||||
query: {
|
||||
@@ -672,11 +673,9 @@ describe('ConversationList', () => {
|
||||
mockChatConversationDetail = {
|
||||
id: 'conversation-1',
|
||||
created_at: 1710000000,
|
||||
introduction: 'Hello there',
|
||||
model_config: {
|
||||
model: 'gpt-4o',
|
||||
configs: {
|
||||
introduction: 'Hello there',
|
||||
},
|
||||
model: { provider: 'openai', name: 'gpt-4o', mode: 'chat', completion_params: {} },
|
||||
user_input_form: [
|
||||
{
|
||||
query: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { FC } from 'react'
|
||||
import type { QueryParam } from './index'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import { RiCalendarLine } from '@remixicon/react'
|
||||
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import quarterOfYear from 'dayjs/plugin/quarterOfYear'
|
||||
import * as React from 'react'
|
||||
@@ -10,7 +11,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import Chip from '@/app/components/base/chip'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import Sort from '@/app/components/base/sort'
|
||||
import { useAnnotationsCount } from '@/service/use-log'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
import {
|
||||
CLOUD_SANDBOX_CLEARED_TIME_PERIOD,
|
||||
CLOUD_SANDBOX_TIME_PERIOD_KEYS,
|
||||
@@ -50,7 +51,11 @@ const Filter: FC<IFilterProps> = ({
|
||||
queryParams,
|
||||
setQueryParams,
|
||||
}: IFilterProps) => {
|
||||
const { data, isLoading } = useAnnotationsCount(appId)
|
||||
const { data, isLoading } = useQuery(
|
||||
consoleQuery.apps.byAppId.annotations.count.get.queryOptions({
|
||||
input: appId ? { params: { app_id: appId } } : skipToken,
|
||||
}),
|
||||
)
|
||||
const { t } = useTranslation(['appLog', 'common'])
|
||||
const planState = useCloudSandboxPlanStatus()
|
||||
const isTimePeriodRestricted = isLogTimePeriodRestricted(planState)
|
||||
@@ -86,7 +91,10 @@ const Filter: FC<IFilterProps> = ({
|
||||
showLeftIcon={false}
|
||||
value={queryParams.annotation_status || 'all'}
|
||||
onSelect={(item) => {
|
||||
setQueryParams({ ...queryParams, annotation_status: item.value as string })
|
||||
setQueryParams({
|
||||
...queryParams,
|
||||
annotation_status: item.value as QueryParam['annotation_status'],
|
||||
})
|
||||
}}
|
||||
onClear={() => setQueryParams({ ...queryParams, annotation_status: 'all' })}
|
||||
items={[
|
||||
@@ -123,7 +131,7 @@ const Filter: FC<IFilterProps> = ({
|
||||
},
|
||||
]}
|
||||
onSelect={(value) => {
|
||||
setQueryParams({ ...queryParams, sort_by: value as string })
|
||||
setQueryParams({ ...queryParams, sort_by: value as QueryParam['sort_by'] })
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type {
|
||||
AppDetailWithSite,
|
||||
GetAppsByAppIdChatConversationsData,
|
||||
} from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { FC } from 'react'
|
||||
import { Pagination } from '@langgenius/dify-ui/pagination'
|
||||
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import dayjs from 'dayjs'
|
||||
import { omit } from 'es-toolkit/object'
|
||||
@@ -13,7 +17,7 @@ import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { useChatConversations, useCompletionConversations } from '@/service/use-log'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import PageTitle from '../log-annotation/page-title'
|
||||
import {
|
||||
@@ -30,11 +34,13 @@ type ILogsProps = {
|
||||
appDetail: AppDetailWithSite
|
||||
}
|
||||
|
||||
type ConversationsQuery = NonNullable<GetAppsByAppIdChatConversationsData['query']>
|
||||
|
||||
export type QueryParam = {
|
||||
period: string
|
||||
annotation_status?: string
|
||||
annotation_status?: ConversationsQuery['annotation_status']
|
||||
keyword?: string
|
||||
sort_by?: string
|
||||
sort_by?: ConversationsQuery['sort_by']
|
||||
}
|
||||
|
||||
const defaultQueryParams: QueryParam = {
|
||||
@@ -115,16 +121,17 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
}
|
||||
|
||||
// When the details are obtained, proceed to the next request
|
||||
const { data: chatConversations, refetch: mutateChatList } = useChatConversations({
|
||||
appId: isChatMode ? appDetail.id : '',
|
||||
params: query,
|
||||
})
|
||||
const { data: chatConversations, refetch: mutateChatList } = useQuery(
|
||||
consoleQuery.apps.byAppId.chatConversations.get.queryOptions({
|
||||
input: isChatMode ? { params: { app_id: appDetail.id }, query } : skipToken,
|
||||
}),
|
||||
)
|
||||
|
||||
const { data: completionConversations, refetch: mutateCompletionList } =
|
||||
useCompletionConversations({
|
||||
appId: !isChatMode ? appDetail.id : '',
|
||||
params: query,
|
||||
})
|
||||
const { data: completionConversations, refetch: mutateCompletionList } = useQuery(
|
||||
consoleQuery.apps.byAppId.completionConversations.get.queryOptions({
|
||||
input: !isChatMode ? { params: { app_id: appDetail.id }, query } : skipToken,
|
||||
}),
|
||||
)
|
||||
|
||||
const total = isChatMode ? chatConversations?.total : completionConversations?.total
|
||||
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ChatItemInTree } from '../../base/chat/types'
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
import type { FeedbackType, IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
import type { Annotation, ChatMessage } from '@/models/log'
|
||||
import type { FileResponse } from '@/types/workflow'
|
||||
import dayjs from 'dayjs'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import { get } from 'es-toolkit/compat'
|
||||
import { isPlainObject } from 'es-toolkit/predicate'
|
||||
import { buildChatItemTree, getThreadMessages } from '@/app/components/base/chat/utils'
|
||||
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
|
||||
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
|
||||
@@ -15,26 +16,27 @@ dayjs.extend(timezone)
|
||||
|
||||
type LogDetailMessage = {
|
||||
answer?: string | number | null
|
||||
inputs?: Record<string, string>
|
||||
message_files?: Array<{ url?: string }>
|
||||
inputs?: Record<string, unknown>
|
||||
message_files?: Array<{ url?: string | null }>
|
||||
query?: string
|
||||
}
|
||||
|
||||
type UserInputFormItem = Record<string, { variable?: string }>
|
||||
|
||||
type ConversationLogDetail = {
|
||||
from_account_name?: string
|
||||
from_end_user_session_id?: string
|
||||
message?: LogDetailMessage
|
||||
from_account_name?: string | null
|
||||
from_end_user_session_id?: string | null
|
||||
message?: LogDetailMessage | null
|
||||
message_count?: number
|
||||
model_config?: {
|
||||
user_input_form?: UserInputFormItem[]
|
||||
}
|
||||
// Untyped JSON in the console API response.
|
||||
user_input_form?: unknown
|
||||
} | null
|
||||
name?: string
|
||||
}
|
||||
|
||||
const getUserInputVariable = (item: UserInputFormItem) => {
|
||||
const variable = Object.values(item)[0]?.variable
|
||||
const getUserInputVariable = (item: unknown) => {
|
||||
if (!isPlainObject(item)) return undefined
|
||||
const field = Object.values(item)[0]
|
||||
const variable = isPlainObject(field) ? field.variable : undefined
|
||||
|
||||
return typeof variable === 'string' ? variable : undefined
|
||||
}
|
||||
@@ -305,10 +307,12 @@ export const getConversationRowValues = ({
|
||||
noOutputLabel: string
|
||||
}) => {
|
||||
const endUser = log.from_end_user_session_id || log.from_account_name
|
||||
const leftValue =
|
||||
const rawLeftValue: unknown =
|
||||
get(log, isChatMode ? 'name' : 'message.inputs.query') ||
|
||||
(!isChatMode ? get(log, 'message.query') || get(log, 'message.inputs.default_input') : '') ||
|
||||
''
|
||||
const leftValue =
|
||||
typeof rawLeftValue === 'string' || typeof rawLeftValue === 'number' ? rawLeftValue : ''
|
||||
const rightValue = get(log, isChatMode ? 'message_count' : 'message.answer')
|
||||
|
||||
return {
|
||||
@@ -323,8 +327,11 @@ export const getConversationRowValues = ({
|
||||
export const getDetailVarList = (
|
||||
detail: ConversationLogDetail,
|
||||
varValues: Record<string, string>,
|
||||
) =>
|
||||
detail.model_config?.user_input_form?.flatMap((item) => {
|
||||
) => {
|
||||
const userInputForm = detail.model_config?.user_input_form
|
||||
if (!Array.isArray(userInputForm)) return []
|
||||
|
||||
return userInputForm.flatMap((item) => {
|
||||
const variable = getUserInputVariable(item)
|
||||
|
||||
if (!variable) return []
|
||||
@@ -339,7 +346,8 @@ export const getDetailVarList = (
|
||||
value,
|
||||
},
|
||||
]
|
||||
}) || []
|
||||
})
|
||||
}
|
||||
|
||||
export const getCompletionMessageFiles = (detail: ConversationLogDetail, isChatMode: boolean) => {
|
||||
const messageFiles = detail.message?.message_files
|
||||
@@ -348,3 +356,25 @@ export const getCompletionMessageFiles = (detail: ConversationLogDetail, isChatM
|
||||
|
||||
return messageFiles.flatMap((item) => (item.url ? [item.url] : []))
|
||||
}
|
||||
|
||||
// `model_config.model` is untyped JSON in the console API response.
|
||||
export const getLogModelInfo = (model: unknown) => {
|
||||
if (!isPlainObject(model)) return undefined
|
||||
const { name, provider, completion_params } = model
|
||||
if (typeof name !== 'string' || typeof provider !== 'string') return undefined
|
||||
|
||||
return {
|
||||
name,
|
||||
provider,
|
||||
completion_params: isPlainObject(completion_params) ? completion_params : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const getAdminFeedback = (
|
||||
feedbacks: Array<{ content?: string | null; from_source: string; rating: string }>,
|
||||
): FeedbackType | undefined => {
|
||||
const feedback = feedbacks.find((item) => item.from_source === 'admin')
|
||||
if (feedback?.rating !== 'like' && feedback?.rating !== 'dislike') return undefined
|
||||
|
||||
return { rating: feedback.rating, content: feedback.content }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type {
|
||||
AppDetailWithSite,
|
||||
Conversation,
|
||||
ConversationDetail,
|
||||
ConversationMessageDetail,
|
||||
ConversationPagination,
|
||||
ConversationWithSummary,
|
||||
ConversationWithSummaryPagination,
|
||||
} from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { FC } from 'react'
|
||||
import type { ChatItemInTree } from '../../base/chat/types'
|
||||
import type {
|
||||
@@ -9,14 +17,7 @@ import type {
|
||||
IChatItem,
|
||||
SubmitAnnotationFunc,
|
||||
} from '@/app/components/base/chat/chat/type'
|
||||
import type {
|
||||
ChatConversationGeneralDetail,
|
||||
ChatConversationsResponse,
|
||||
ChatMessagesRequest,
|
||||
CompletionConversationGeneralDetail,
|
||||
CompletionConversationsResponse,
|
||||
LogAnnotation,
|
||||
} from '@/models/log'
|
||||
import type { ChatMessagesRequest, LogAnnotation } from '@/models/log'
|
||||
import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
@@ -33,7 +34,7 @@ import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { StatusDot } from '@langgenius/dify-ui/status-dot'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiCloseLine, RiEditFill } from '@remixicon/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
@@ -54,13 +55,13 @@ import { toast } from '@/app/notifications'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
import {
|
||||
fetchChatMessages,
|
||||
updateLogMessageAnnotations,
|
||||
updateLogMessageFeedbacks,
|
||||
} from '@/service/log'
|
||||
import { AppSourceType } from '@/service/share'
|
||||
import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import PromptLogModal from '../../base/prompt-log-modal'
|
||||
import {
|
||||
@@ -68,10 +69,12 @@ import {
|
||||
applyAnnotationEdited,
|
||||
applyAnnotationRemoved,
|
||||
buildChatThreadState,
|
||||
getAdminFeedback,
|
||||
getCompletionMessageFiles,
|
||||
getConversationRowValues,
|
||||
getDetailVarList,
|
||||
getFormattedChatList,
|
||||
getLogModelInfo,
|
||||
getThreadChatItems,
|
||||
isNearTopLoadMore,
|
||||
mergePaginatedChatItems,
|
||||
@@ -79,14 +82,14 @@ import {
|
||||
} from './list-utils'
|
||||
import VarPanel from './var-panel'
|
||||
|
||||
type ConversationListItem = ChatConversationGeneralDetail | CompletionConversationGeneralDetail
|
||||
type ConversationListItem = ConversationWithSummary | Conversation
|
||||
type ConversationSelection = ConversationListItem | { id: string; isPlaceholder?: true }
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
|
||||
type IConversationList = {
|
||||
logs?: ChatConversationsResponse | CompletionConversationsResponse
|
||||
logs?: ConversationWithSummaryPagination | ConversationPagination
|
||||
appDetail: AppDetailWithSite
|
||||
onRefresh: () => void
|
||||
}
|
||||
@@ -157,7 +160,7 @@ const statusTdRender = (statusCount: StatusCount) => {
|
||||
|
||||
type IDetailPanel = {
|
||||
appDetail: AppDetailWithSite
|
||||
detail: any
|
||||
detail: ConversationDetail | ConversationMessageDetail
|
||||
onClose: () => void
|
||||
onFeedback: FeedbackFunc
|
||||
onSubmitAnnotation: SubmitAnnotationFunc
|
||||
@@ -247,12 +250,12 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) {
|
||||
const nextThreadState = buildChatThreadState({
|
||||
allChatItems,
|
||||
hasMore,
|
||||
introduction: detail?.model_config?.configs?.introduction,
|
||||
introduction: 'introduction' in detail ? (detail.introduction ?? undefined) : undefined,
|
||||
})
|
||||
setChatItemTree(nextThreadState.chatItemTree)
|
||||
setThreadChatItems(nextThreadState.threadChatItems)
|
||||
if (nextThreadState.oldestAnswerId) oldestAnswerIdRef.current = nextThreadState.oldestAnswerId
|
||||
}, [allChatItems, hasMore, detail?.model_config?.configs?.introduction])
|
||||
}, [allChatItems, hasMore, detail])
|
||||
|
||||
const switchSibling = useCallback(
|
||||
(siblingMessageId: string) => {
|
||||
@@ -412,6 +415,8 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) {
|
||||
|
||||
const varList = getDetailVarList(detail, varValues)
|
||||
const message_files = getCompletionMessageFiles(detail, isChatMode)
|
||||
const completionMessage = 'message' in detail ? detail.message : undefined
|
||||
const modelInfo = getLogModelInfo(detail.model_config?.model)
|
||||
|
||||
const [width, setWidth] = useState(0)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
@@ -449,15 +454,17 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) {
|
||||
)}
|
||||
{!isChatMode && (
|
||||
<div className="system-2xs-regular-uppercase text-text-secondary">
|
||||
{formatTime(
|
||||
detail.created_at,
|
||||
t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string,
|
||||
)}
|
||||
{detail.created_at
|
||||
? formatTime(
|
||||
detail.created_at,
|
||||
t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string,
|
||||
)
|
||||
: defaultValue}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex grow flex-wrap items-center justify-end gap-y-1">
|
||||
{!isAdvanced && <ModelInfo model={detail.model_config.model} />}
|
||||
{!isAdvanced && modelInfo && <ModelInfo model={modelInfo} />}
|
||||
</div>
|
||||
<IconButton
|
||||
size="lg"
|
||||
@@ -477,35 +484,37 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) {
|
||||
</div>
|
||||
<div className="mx-1 mb-1 grow overflow-auto rounded-b-xl bg-background-section-burn">
|
||||
{!isChatMode ? (
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex h-4.5 items-center space-x-3">
|
||||
<div className="system-xs-semibold-uppercase text-text-tertiary">
|
||||
{t(($) => $['table.header.output'], { ns: 'appLog' })}
|
||||
completionMessage && (
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex h-4.5 items-center space-x-3">
|
||||
<div className="system-xs-semibold-uppercase text-text-tertiary">
|
||||
{t(($) => $['table.header.output'], { ns: 'appLog' })}
|
||||
</div>
|
||||
<div
|
||||
className="h-px grow"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(270deg, rgba(243, 244, 246, 0) 0%, rgb(243, 244, 246) 100%)',
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
className="h-px grow"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(270deg, rgba(243, 244, 246, 0) 0%, rgb(243, 244, 246) 100%)',
|
||||
<TextGeneration
|
||||
appSourceType={AppSourceType.webApp}
|
||||
className="mt-2"
|
||||
content={completionMessage.answer}
|
||||
messageId={completionMessage.id}
|
||||
isError={false}
|
||||
onRetry={noop}
|
||||
supportFeedback
|
||||
feedback={getAdminFeedback(completionMessage.feedbacks)}
|
||||
onFeedback={(feedback) => {
|
||||
onFeedback(completionMessage.id, feedback).catch(noop)
|
||||
}}
|
||||
></div>
|
||||
isShowTextToSpeech
|
||||
siteInfo={null}
|
||||
/>
|
||||
</div>
|
||||
<TextGeneration
|
||||
appSourceType={AppSourceType.webApp}
|
||||
className="mt-2"
|
||||
content={detail.message.answer}
|
||||
messageId={detail.message.id}
|
||||
isError={false}
|
||||
onRetry={noop}
|
||||
supportFeedback
|
||||
feedback={detail.message.feedbacks.find((item: any) => item.from_source === 'admin')}
|
||||
onFeedback={(feedback) => {
|
||||
onFeedback(detail.message.id, feedback).catch(noop)
|
||||
}}
|
||||
isShowTextToSpeech
|
||||
siteInfo={null}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : threadChatItems.length < MIN_ITEMS_FOR_SCROLL_LOADING ? (
|
||||
<div className="mb-4 pt-4">
|
||||
<Chat
|
||||
@@ -632,8 +641,13 @@ const CompletionConversationDetailComp: FC<ConversationDetailProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
// Text Generator App Session Details Including Message List
|
||||
const { data: conversationDetail, refetch: conversationDetailMutate } =
|
||||
useCompletionConversationDetail(appDetail.id, conversationId)
|
||||
const { data: conversationDetail, refetch: conversationDetailMutate } = useQuery(
|
||||
consoleQuery.apps.byAppId.completionConversations.byConversationId.get.queryOptions({
|
||||
input: conversationId
|
||||
? { params: { app_id: appDetail.id, conversation_id: conversationId } }
|
||||
: skipToken,
|
||||
}),
|
||||
)
|
||||
const { t } = useTranslation(['appLog', 'common'])
|
||||
|
||||
const handleFeedback = async (mid: string, { rating, content }: FeedbackType) => {
|
||||
@@ -687,7 +701,13 @@ const ChatConversationDetailComp: FC<ConversationDetailProps> = ({
|
||||
conversationId,
|
||||
onClose,
|
||||
}) => {
|
||||
const { data: conversationDetail } = useChatConversationDetail(appDetail.id, conversationId)
|
||||
const { data: conversationDetail } = useQuery(
|
||||
consoleQuery.apps.byAppId.chatConversations.byConversationId.get.queryOptions({
|
||||
input: conversationId
|
||||
? { params: { app_id: appDetail.id, conversation_id: conversationId } }
|
||||
: skipToken,
|
||||
}),
|
||||
)
|
||||
const { t } = useTranslation(['appLog', 'common'])
|
||||
|
||||
const handleFeedback = async (mid: string, { rating, content }: FeedbackType) => {
|
||||
|
||||
@@ -214,7 +214,7 @@ describe('Filter', () => {
|
||||
])('should display correct label for %s status', (statusValue, expectedLabel) => {
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams({ status: statusValue })}
|
||||
queryParams={createDefaultQueryParams({ status: statusValue as QueryParam['status'] })}
|
||||
setQueryParams={defaultSetQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type {
|
||||
AppDetailWithSite,
|
||||
GetAppsByAppIdWorkflowAppLogsData,
|
||||
WorkflowAppLogPaginationResponse,
|
||||
WorkflowAppLogPartialResponse,
|
||||
WorkflowRunForLogResponse,
|
||||
} from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
/**
|
||||
* Logs Container Component Tests
|
||||
@@ -15,17 +21,14 @@ import type { UseQueryResult } from '@tanstack/react-query'
|
||||
* - detail.spec.tsx
|
||||
* - trigger-by-display.spec.tsx
|
||||
*/
|
||||
import type { MockedFunction } from 'vite-plus/test'
|
||||
import type { CloudSandboxPlanState } from '../../log/cloud-sandbox-retention'
|
||||
import type { ILogsProps } from '../index'
|
||||
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log'
|
||||
import type { AppModeEnum } from '@/types/app'
|
||||
import { screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import dayjs from 'dayjs'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import { WorkflowRunTriggeredFrom } from '@/models/log'
|
||||
import * as useLogModule from '@/service/use-log'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app'
|
||||
@@ -41,7 +44,26 @@ const mockPlanState = vi.hoisted(() => ({
|
||||
value: 'unrestricted' as CloudSandboxPlanState,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-log')
|
||||
type WorkflowLogsInput = {
|
||||
params: { app_id: string }
|
||||
query?: GetAppsByAppIdWorkflowAppLogsData['query']
|
||||
}
|
||||
|
||||
const mockedUseWorkflowLogs =
|
||||
vi.fn<(input: WorkflowLogsInput) => UseQueryResult<WorkflowAppLogPaginationResponse, Error>>()
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useQuery: (options: Parameters<typeof actual.useQuery>[0]) => {
|
||||
const [path, meta] = options.queryKey as [string[], { input?: WorkflowLogsInput }?]
|
||||
if (Array.isArray(path) && path.includes('workflowAppLogs') && meta?.input)
|
||||
return mockedUseWorkflowLogs(meta.input)
|
||||
return actual.useQuery(options)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../log/cloud-sandbox-retention', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../log/cloud-sandbox-retention')>()
|
||||
@@ -106,10 +128,6 @@ vi.mock('@/app/components/workflow/context', () => ({
|
||||
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
const mockedUseWorkflowLogs = useLogModule.useWorkflowLogs as MockedFunction<
|
||||
typeof useLogModule.useWorkflowLogs
|
||||
>
|
||||
|
||||
// ============================================================================
|
||||
// Test Utilities
|
||||
// ============================================================================
|
||||
@@ -180,14 +198,14 @@ const createMockApp = (overrides: Partial<AppDetailWithSite> = {}): AppDetailWit
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockWorkflowRun = (overrides: Partial<WorkflowRunDetail> = {}): WorkflowRunDetail => ({
|
||||
const createMockWorkflowRun = (
|
||||
overrides: Partial<WorkflowRunForLogResponse> = {},
|
||||
): WorkflowRunForLogResponse => ({
|
||||
id: 'run-1',
|
||||
version: '1.0.0',
|
||||
status: 'succeeded',
|
||||
elapsed_time: 1.234,
|
||||
total_tokens: 100,
|
||||
total_price: 0.001,
|
||||
currency: 'USD',
|
||||
total_steps: 5,
|
||||
finished_at: Date.now(),
|
||||
triggered_from: WorkflowRunTriggeredFrom.APP_RUN,
|
||||
@@ -195,8 +213,8 @@ const createMockWorkflowRun = (overrides: Partial<WorkflowRunDetail> = {}): Work
|
||||
})
|
||||
|
||||
const createMockWorkflowLog = (
|
||||
overrides: Partial<WorkflowAppLogDetail> = {},
|
||||
): WorkflowAppLogDetail => ({
|
||||
overrides: Partial<WorkflowAppLogPartialResponse> = {},
|
||||
): WorkflowAppLogPartialResponse => ({
|
||||
id: 'log-1',
|
||||
workflow_run: createMockWorkflowRun(),
|
||||
created_from: 'web-app',
|
||||
@@ -211,9 +229,9 @@ const createMockWorkflowLog = (
|
||||
})
|
||||
|
||||
const createMockLogsResponse = (
|
||||
data: WorkflowAppLogDetail[] = [],
|
||||
data: WorkflowAppLogPartialResponse[] = [],
|
||||
total = data.length,
|
||||
): WorkflowLogsResponse => ({
|
||||
): WorkflowAppLogPaginationResponse => ({
|
||||
data,
|
||||
has_more: data.length < total,
|
||||
limit: APP_PAGE_LIMIT,
|
||||
@@ -225,14 +243,9 @@ const createMockLogsResponse = (
|
||||
// Type-safe Mock Helper
|
||||
// ============================================================================
|
||||
|
||||
type WorkflowLogsParams = {
|
||||
appId: string
|
||||
params?: Record<string, string | number | boolean | undefined>
|
||||
}
|
||||
|
||||
const getMockCallParams = (): WorkflowLogsParams | undefined => {
|
||||
const lastCall = mockedUseWorkflowLogs.mock.calls.at(-1)
|
||||
return lastCall?.[0]
|
||||
const getMockCallParams = () => {
|
||||
const input = mockedUseWorkflowLogs.mock.calls.at(-1)?.[0]
|
||||
return input && { appId: input.params.app_id, params: input.query }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -256,7 +269,7 @@ describe('Logs Container', () => {
|
||||
it('should render title and subtitle', () => {
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
createMockQueryResult<WorkflowAppLogPaginationResponse>({
|
||||
data: createMockLogsResponse([], 0),
|
||||
}),
|
||||
)
|
||||
@@ -272,7 +285,7 @@ describe('Logs Container', () => {
|
||||
it('should render Filter component', () => {
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
createMockQueryResult<WorkflowAppLogPaginationResponse>({
|
||||
data: createMockLogsResponse([], 0),
|
||||
}),
|
||||
)
|
||||
@@ -293,7 +306,7 @@ describe('Logs Container', () => {
|
||||
it('should show loading spinner when data is undefined', () => {
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
createMockQueryResult<WorkflowAppLogPaginationResponse>({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
}),
|
||||
@@ -309,7 +322,7 @@ describe('Logs Container', () => {
|
||||
it('should not show loading spinner when data is available', () => {
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
createMockQueryResult<WorkflowAppLogPaginationResponse>({
|
||||
data: createMockLogsResponse([createMockWorkflowLog()], 1),
|
||||
}),
|
||||
)
|
||||
@@ -329,7 +342,7 @@ describe('Logs Container', () => {
|
||||
it('should render empty element when total is 0', () => {
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
createMockQueryResult<WorkflowAppLogPaginationResponse>({
|
||||
data: createMockLogsResponse([], 0),
|
||||
}),
|
||||
)
|
||||
@@ -350,7 +363,7 @@ describe('Logs Container', () => {
|
||||
it('should call useWorkflowLogs with correct appId and default params', () => {
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
createMockQueryResult<WorkflowAppLogPaginationResponse>({
|
||||
data: createMockLogsResponse([], 0),
|
||||
}),
|
||||
)
|
||||
@@ -373,7 +386,7 @@ describe('Logs Container', () => {
|
||||
it('should include date filters for non-allTime periods', () => {
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
createMockQueryResult<WorkflowAppLogPaginationResponse>({
|
||||
data: createMockLogsResponse([], 0),
|
||||
}),
|
||||
)
|
||||
@@ -390,7 +403,7 @@ describe('Logs Container', () => {
|
||||
it('should not include status param when status is all', () => {
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
createMockQueryResult<WorkflowAppLogPaginationResponse>({
|
||||
data: createMockLogsResponse([], 0),
|
||||
}),
|
||||
)
|
||||
@@ -412,7 +425,7 @@ describe('Logs Container', () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
createMockQueryResult<WorkflowAppLogPaginationResponse>({
|
||||
data: createMockLogsResponse([], 0),
|
||||
}),
|
||||
)
|
||||
@@ -436,7 +449,7 @@ describe('Logs Container', () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
createMockQueryResult<WorkflowAppLogPaginationResponse>({
|
||||
data: createMockLogsResponse([], 0),
|
||||
}),
|
||||
)
|
||||
@@ -459,7 +472,7 @@ describe('Logs Container', () => {
|
||||
const user = userEvent.setup()
|
||||
mockPlanState.value = 'sandbox'
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
createMockQueryResult<WorkflowAppLogPaginationResponse>({
|
||||
data: createMockLogsResponse([], 0),
|
||||
}),
|
||||
)
|
||||
@@ -482,7 +495,7 @@ describe('Logs Container', () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
createMockQueryResult<WorkflowAppLogPaginationResponse>({
|
||||
data: createMockLogsResponse([], 0),
|
||||
}),
|
||||
)
|
||||
@@ -510,7 +523,7 @@ describe('Logs Container', () => {
|
||||
it('should not render pagination when total is less than limit', () => {
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
createMockQueryResult<WorkflowAppLogPaginationResponse>({
|
||||
data: createMockLogsResponse([createMockWorkflowLog()], 1),
|
||||
}),
|
||||
)
|
||||
@@ -529,7 +542,7 @@ describe('Logs Container', () => {
|
||||
)
|
||||
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
createMockQueryResult<WorkflowAppLogPaginationResponse>({
|
||||
data: createMockLogsResponse(logs, APP_PAGE_LIMIT + 10),
|
||||
}),
|
||||
)
|
||||
@@ -549,7 +562,7 @@ describe('Logs Container', () => {
|
||||
it('should render List component when data is available', () => {
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
createMockQueryResult<WorkflowAppLogPaginationResponse>({
|
||||
data: createMockLogsResponse([createMockWorkflowLog()], 1),
|
||||
}),
|
||||
)
|
||||
@@ -564,7 +577,7 @@ describe('Logs Container', () => {
|
||||
it('should display log data in table', () => {
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
createMockQueryResult<WorkflowAppLogPaginationResponse>({
|
||||
data: createMockLogsResponse(
|
||||
[
|
||||
createMockWorkflowLog({
|
||||
@@ -607,7 +620,7 @@ describe('Logs Container', () => {
|
||||
it('should handle different app modes', () => {
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
createMockQueryResult<WorkflowAppLogPaginationResponse>({
|
||||
data: createMockLogsResponse([createMockWorkflowLog()], 1),
|
||||
}),
|
||||
)
|
||||
@@ -624,7 +637,7 @@ describe('Logs Container', () => {
|
||||
it('should handle error state from useWorkflowLogs', () => {
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
createMockQueryResult<WorkflowAppLogPaginationResponse>({
|
||||
data: undefined,
|
||||
error: new Error('Failed to fetch'),
|
||||
}),
|
||||
@@ -640,7 +653,7 @@ describe('Logs Container', () => {
|
||||
it('should handle app with different ID', () => {
|
||||
// Arrange
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
createMockQueryResult<WorkflowAppLogPaginationResponse>({
|
||||
data: createMockLogsResponse([], 0),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log'
|
||||
import type {
|
||||
AppDetailWithSite,
|
||||
WorkflowAppLogPaginationResponse,
|
||||
WorkflowAppLogPartialResponse,
|
||||
WorkflowRunForLogResponse,
|
||||
} from '@dify/contracts/api/console/apps/types.gen'
|
||||
/**
|
||||
* WorkflowAppLogList Component Tests
|
||||
*
|
||||
@@ -99,14 +103,14 @@ const createMockApp = (overrides: Partial<AppDetailWithSite> = {}): AppDetailWit
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockWorkflowRun = (overrides: Partial<WorkflowRunDetail> = {}): WorkflowRunDetail => ({
|
||||
const createMockWorkflowRun = (
|
||||
overrides: Partial<WorkflowRunForLogResponse> = {},
|
||||
): WorkflowRunForLogResponse => ({
|
||||
id: 'run-1',
|
||||
version: '1.0.0',
|
||||
status: 'succeeded',
|
||||
elapsed_time: 1.234,
|
||||
total_tokens: 100,
|
||||
total_price: 0.001,
|
||||
currency: 'USD',
|
||||
total_steps: 5,
|
||||
finished_at: Date.now(),
|
||||
triggered_from: WorkflowRunTriggeredFrom.APP_RUN,
|
||||
@@ -114,8 +118,9 @@ const createMockWorkflowRun = (overrides: Partial<WorkflowRunDetail> = {}): Work
|
||||
})
|
||||
|
||||
const createMockWorkflowLog = (
|
||||
overrides: Partial<WorkflowAppLogDetail> = {},
|
||||
): WorkflowAppLogDetail => ({
|
||||
// read_at is not declared by the console response yet; the list still honours it.
|
||||
overrides: Partial<WorkflowAppLogPartialResponse> & { read_at?: number } = {},
|
||||
): WorkflowAppLogPartialResponse => ({
|
||||
id: 'log-1',
|
||||
workflow_run: createMockWorkflowRun(),
|
||||
created_from: 'web-app',
|
||||
@@ -130,9 +135,9 @@ const createMockWorkflowLog = (
|
||||
})
|
||||
|
||||
const createMockLogsResponse = (
|
||||
data: WorkflowAppLogDetail[] = [],
|
||||
data: WorkflowAppLogPartialResponse[] = [],
|
||||
total = data.length,
|
||||
): WorkflowLogsResponse => ({
|
||||
): WorkflowAppLogPaginationResponse => ({
|
||||
data,
|
||||
has_more: data.length < total,
|
||||
limit: APP_PAGE_LIMIT,
|
||||
|
||||
@@ -54,7 +54,7 @@ const Filter: FC<IFilterProps> = ({ queryParams, setQueryParams }: IFilterProps)
|
||||
<Chip
|
||||
value={queryParams.status || 'all'}
|
||||
onSelect={(item) => {
|
||||
setQueryParams({ ...queryParams, status: item.value as string })
|
||||
setQueryParams({ ...queryParams, status: item.value as QueryParam['status'] })
|
||||
trackEvent('workflow_log_filter_status_selected', {
|
||||
workflow_log_filter_status: item.value as string,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type {
|
||||
AppDetailWithSite,
|
||||
GetAppsByAppIdWorkflowAppLogsData,
|
||||
} from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { FC } from 'react'
|
||||
import { Pagination } from '@langgenius/dify-ui/pagination'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
@@ -16,7 +19,7 @@ import EmptyElement from '@/app/components/app/log/empty-element'
|
||||
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { useWorkflowLogs } from '@/service/use-log'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
import PageTitle from '../log-annotation/page-title'
|
||||
import { ArchivedLogsNotice } from '../log/archived-logs-notice'
|
||||
import { shouldShowArchivedLogsNotice } from '../log/archived-logs-notice-utils'
|
||||
@@ -36,9 +39,11 @@ export type ILogsProps = {
|
||||
appDetail: AppDetailWithSite
|
||||
}
|
||||
|
||||
type WorkflowLogStatus = NonNullable<GetAppsByAppIdWorkflowAppLogsData['query']>['status']
|
||||
|
||||
export type QueryParam = {
|
||||
period: string
|
||||
status?: string
|
||||
status?: WorkflowLogStatus | 'all'
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
@@ -81,10 +86,11 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
...omit(requestQueryParams, ['period', 'status']),
|
||||
}
|
||||
|
||||
const { data: workflowLogs, refetch: mutate } = useWorkflowLogs({
|
||||
appId: appDetail.id,
|
||||
params: query,
|
||||
})
|
||||
const { data: workflowLogs, refetch: mutate } = useQuery(
|
||||
consoleQuery.apps.byAppId.workflowAppLogs.get.queryOptions({
|
||||
input: { params: { app_id: appDetail.id }, query },
|
||||
}),
|
||||
)
|
||||
const total = workflowLogs?.total
|
||||
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
|
||||
const showArchivedLogsNotice = shouldShowArchivedLogsNotice(
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { FC } from 'react'
|
||||
import type {
|
||||
WorkflowAppLogDetail,
|
||||
WorkflowLogsResponse,
|
||||
WorkflowRunTriggeredFrom,
|
||||
} from '@/models/log'
|
||||
AppDetailWithSite,
|
||||
WorkflowAppLogPaginationResponse,
|
||||
WorkflowAppLogPartialResponse,
|
||||
} from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { FC } from 'react'
|
||||
import type { WorkflowLogDetails, WorkflowRunTriggeredFrom } from '@/models/log'
|
||||
import { ArrowDownIcon } from '@heroicons/react/24/outline'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
@@ -30,7 +30,7 @@ import DetailPanel from './detail'
|
||||
import TriggerByDisplay from './trigger-by-display'
|
||||
|
||||
type ILogs = {
|
||||
logs?: WorkflowLogsResponse
|
||||
logs?: WorkflowAppLogPaginationResponse
|
||||
appDetail?: AppDetailWithSite
|
||||
onRefresh: () => void
|
||||
}
|
||||
@@ -45,9 +45,9 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
|
||||
const isMobile = media === MediaType.mobile
|
||||
|
||||
const [showDrawer, setShowDrawer] = useState<boolean>(false)
|
||||
const [currentLog, setCurrentLog] = useState<WorkflowAppLogDetail | undefined>()
|
||||
const [currentLog, setCurrentLog] = useState<WorkflowAppLogPartialResponse | undefined>()
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
|
||||
const [localLogs, setLocalLogs] = useState<WorkflowAppLogDetail[]>(logs?.data || [])
|
||||
const [localLogs, setLocalLogs] = useState<WorkflowAppLogPartialResponse[]>(logs?.data || [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!logs?.data) {
|
||||
@@ -56,7 +56,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
|
||||
}
|
||||
|
||||
const sortedLogs = [...logs.data].sort((a, b) => {
|
||||
const result = a.created_at - b.created_at
|
||||
const result = (a.created_at ?? 0) - (b.created_at ?? 0)
|
||||
return sortOrder === 'asc' ? result : -result
|
||||
})
|
||||
|
||||
@@ -69,7 +69,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
|
||||
|
||||
const isWorkflow = appDetail?.mode === AppModeEnum.WORKFLOW
|
||||
|
||||
const statusTdRender = (status: string) => {
|
||||
const statusTdRender = (status?: string | null) => {
|
||||
if (status === 'succeeded') {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
|
||||
@@ -209,7 +209,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="system-sm-regular text-text-secondary">
|
||||
{localLogs.map((log: WorkflowAppLogDetail) => {
|
||||
{localLogs.map((log: WorkflowAppLogPartialResponse) => {
|
||||
const endUser = log.created_by_end_user
|
||||
? log.created_by_end_user.session_id
|
||||
: log.created_by_account
|
||||
@@ -230,7 +230,8 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
|
||||
}}
|
||||
>
|
||||
<td className="h-4">
|
||||
{!log.read_at && (
|
||||
{/* The console workflow-log response does not declare read_at yet. */}
|
||||
{!('read_at' in log && log.read_at) && (
|
||||
<div className="flex items-center p-3 pr-0.5">
|
||||
<span className="inline-block size-1.5 rounded-sm bg-util-colors-blue-blue-500"></span>
|
||||
</div>
|
||||
@@ -246,21 +247,21 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
|
||||
setShowDrawer(true)
|
||||
}}
|
||||
>
|
||||
{formatTime(
|
||||
log.created_at,
|
||||
t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string,
|
||||
)}
|
||||
{log.created_at
|
||||
? formatTime(
|
||||
log.created_at,
|
||||
t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string,
|
||||
)
|
||||
: defaultValue}
|
||||
</DrawerTrigger>
|
||||
</td>
|
||||
<td className="p-3 pr-2">{statusTdRender(log.workflow_run.status)}</td>
|
||||
<td className="p-3 pr-2">{statusTdRender(log.workflow_run?.status)}</td>
|
||||
<td className="p-3 pr-2">
|
||||
<div
|
||||
className={cn(log.workflow_run.elapsed_time === 0 && 'text-text-quaternary')}
|
||||
>
|
||||
{`${log.workflow_run.elapsed_time.toFixed(3)}s`}
|
||||
<div className={cn(!log.workflow_run?.elapsed_time && 'text-text-quaternary')}>
|
||||
{`${(log.workflow_run?.elapsed_time ?? 0).toFixed(3)}s`}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 pr-2">{log.workflow_run.total_tokens}</td>
|
||||
<td className="p-3 pr-2">{log.workflow_run?.total_tokens}</td>
|
||||
<td className="p-3 pr-2">
|
||||
<div
|
||||
className={cn(
|
||||
@@ -274,8 +275,10 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
|
||||
{isWorkflow && (
|
||||
<td className="p-3 pr-2">
|
||||
<TriggerByDisplay
|
||||
triggeredFrom={log.workflow_run.triggered_from as WorkflowRunTriggeredFrom}
|
||||
triggerMetadata={log.details?.trigger_metadata}
|
||||
triggeredFrom={log.workflow_run?.triggered_from as WorkflowRunTriggeredFrom}
|
||||
triggerMetadata={
|
||||
(log.details as WorkflowLogDetails | undefined)?.trigger_metadata
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
@@ -292,10 +295,10 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
|
||||
<DetailPanel
|
||||
appId={appDetail.id}
|
||||
onClose={onCloseDrawer}
|
||||
runID={currentLog?.workflow_run.id || ''}
|
||||
runID={currentLog?.workflow_run?.id || ''}
|
||||
canReplay={
|
||||
currentLog?.workflow_run.triggered_from === 'app-run' ||
|
||||
currentLog?.workflow_run.triggered_from === 'debugging'
|
||||
currentLog?.workflow_run?.triggered_from === 'app-run' ||
|
||||
currentLog?.workflow_run?.triggered_from === 'debugging'
|
||||
}
|
||||
/>
|
||||
</DrawerContent>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { WorkflowPausedDetailsResponse } from '@/models/log'
|
||||
import type { WorkflowPauseDetailsResponse } from '@dify/contracts/api/console/workflow/types.gen'
|
||||
import { skipToken } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { cloneElement, isValidElement } from 'react'
|
||||
import { createDocLinkMock, resolveDocLink } from '../../__tests__/i18n'
|
||||
import Status from '../status'
|
||||
|
||||
const mockDocLink = createDocLinkMock()
|
||||
const mockUseWorkflowPausedDetails = vi.fn()
|
||||
const mockUseQuery = vi.fn()
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const { withSelectorKey, withSelectorKeyProps } = await import('@/test/i18n-mock')
|
||||
@@ -57,14 +58,28 @@ vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => mockDocLink,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-log', () => ({
|
||||
useWorkflowPausedDetails: (params: { workflowRunId: string; enabled?: boolean }) =>
|
||||
mockUseWorkflowPausedDetails(params),
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@tanstack/react-query')>()),
|
||||
useQuery: (options: unknown) => mockUseQuery(options),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/console', () => ({
|
||||
consoleQuery: {
|
||||
workflow: {
|
||||
byWorkflowRunId: {
|
||||
pauseDetails: {
|
||||
get: {
|
||||
queryOptions: (options: unknown) => options,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const createPausedDetails = (
|
||||
overrides: Partial<WorkflowPausedDetailsResponse> = {},
|
||||
): WorkflowPausedDetailsResponse => ({
|
||||
overrides: Partial<WorkflowPauseDetailsResponse> = {},
|
||||
): WorkflowPauseDetailsResponse => ({
|
||||
paused_at: '2026-03-18T00:00:00Z',
|
||||
paused_nodes: [],
|
||||
...overrides,
|
||||
@@ -73,7 +88,7 @@ const createPausedDetails = (
|
||||
describe('Status', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseWorkflowPausedDetails.mockReturnValue({ data: undefined })
|
||||
mockUseQuery.mockReturnValue({ data: undefined })
|
||||
})
|
||||
|
||||
it('renders the scheduled status before execution', () => {
|
||||
@@ -87,10 +102,7 @@ describe('Status', () => {
|
||||
|
||||
expect(screen.getByText('appLog.status.running')).toBeInTheDocument()
|
||||
expect(document.querySelectorAll('.bg-text-quaternary')).toHaveLength(2)
|
||||
expect(mockUseWorkflowPausedDetails).toHaveBeenCalledWith({
|
||||
workflowRunId: 'run-1',
|
||||
enabled: false,
|
||||
})
|
||||
expect(mockUseQuery).toHaveBeenCalledWith({ input: skipToken })
|
||||
})
|
||||
|
||||
it('renders the listening label when the run is waiting for input', () => {
|
||||
@@ -170,14 +182,13 @@ describe('Status', () => {
|
||||
expect(screen.getByText('appLog.status.paused')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflowHumanInput.nodes.humanInput.log.reason')).toBeInTheDocument()
|
||||
expect(document.querySelectorAll('.bg-text-quaternary')).toHaveLength(3)
|
||||
expect(mockUseWorkflowPausedDetails).toHaveBeenCalledWith({
|
||||
workflowRunId: 'run-3',
|
||||
enabled: true,
|
||||
expect(mockUseQuery).toHaveBeenCalledWith({
|
||||
input: { params: { workflow_run_id: 'run-3' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('renders paused human-input reasons and backstage URLs', () => {
|
||||
mockUseWorkflowPausedDetails.mockReturnValue({
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: createPausedDetails({
|
||||
paused_nodes: [
|
||||
{
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { StatusDot } from '@langgenius/dify-ui/status-dot'
|
||||
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import StatusContainer from '@/app/components/workflow/run/status-container'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { useWorkflowPausedDetails } from '@/service/use-log'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
|
||||
type ResultProps = {
|
||||
status: string
|
||||
@@ -31,10 +32,14 @@ const StatusPanel: FC<ResultProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation(['runLog', 'workflow', 'appLog', 'workflowHumanInput'])
|
||||
const docLink = useDocLink()
|
||||
const { data: pausedDetails } = useWorkflowPausedDetails({
|
||||
workflowRunId: workflowRunId || '',
|
||||
enabled: status === 'paused',
|
||||
})
|
||||
const { data: pausedDetails } = useQuery(
|
||||
consoleQuery.workflow.byWorkflowRunId.pauseDetails.get.queryOptions({
|
||||
input:
|
||||
status === 'paused' && workflowRunId
|
||||
? { params: { workflow_run_id: workflowRunId } }
|
||||
: skipToken,
|
||||
}),
|
||||
)
|
||||
|
||||
const pausedReasons = useMemo(() => {
|
||||
const reasons: string[] = []
|
||||
@@ -55,7 +60,7 @@ const StatusPanel: FC<ResultProps> = ({
|
||||
const hasHumanInputNode = paused_nodes.some((node) => node.pause_type.type === 'human_input')
|
||||
if (hasHumanInputNode) {
|
||||
paused_nodes.forEach((node) => {
|
||||
if (node.pause_type.type === 'human_input') {
|
||||
if (node.pause_type.type === 'human_input' && node.pause_type.backstage_input_url) {
|
||||
inputURLs.push(node.pause_type.backstage_input_url)
|
||||
}
|
||||
})
|
||||
|
||||
+1
-181
@@ -3,35 +3,6 @@ import type { Metadata, ThoughtItem } from '@/app/components/base/chat/chat/type
|
||||
import type { Edge, Node } from '@/app/components/workflow/types'
|
||||
import type { VisionFile } from '@/types/app'
|
||||
|
||||
type CompletionParamsType = {
|
||||
max_tokens: number
|
||||
temperature: number
|
||||
top_p: number
|
||||
stop: string[]
|
||||
presence_penalty: number
|
||||
frequency_penalty: number
|
||||
}
|
||||
|
||||
type LogModelConfig = {
|
||||
name: string
|
||||
provider: string
|
||||
completion_params: CompletionParamsType
|
||||
}
|
||||
|
||||
type ModelConfigDetail = {
|
||||
introduction: string
|
||||
prompt_template: string
|
||||
prompt_variables: Array<{
|
||||
key: string
|
||||
name: string
|
||||
description: string
|
||||
type: string | number
|
||||
default: string
|
||||
options: string[]
|
||||
}>
|
||||
completion_params: CompletionParamsType
|
||||
}
|
||||
|
||||
export type LogAnnotation = {
|
||||
id: string
|
||||
content: string
|
||||
@@ -84,98 +55,6 @@ type MessageContent = {
|
||||
parent_message_id: string | null
|
||||
}
|
||||
|
||||
export type CompletionConversationGeneralDetail = {
|
||||
id: string
|
||||
status: 'normal' | 'finished' | 'paused'
|
||||
from_source: 'api' | 'console'
|
||||
from_end_user_id: string
|
||||
from_end_user_session_id: string
|
||||
from_account_id: string
|
||||
read_at: Date
|
||||
created_at: number
|
||||
updated_at: number
|
||||
annotation: Annotation
|
||||
user_feedback_stats: {
|
||||
like: number
|
||||
dislike: number
|
||||
}
|
||||
admin_feedback_stats: {
|
||||
like: number
|
||||
dislike: number
|
||||
}
|
||||
model_config: {
|
||||
provider: string
|
||||
model_id: string
|
||||
configs: Pick<ModelConfigDetail, 'prompt_template'>
|
||||
}
|
||||
message: Pick<MessageContent, 'inputs' | 'query' | 'answer' | 'message'>
|
||||
}
|
||||
|
||||
export type CompletionConversationFullDetailResponse = {
|
||||
id: string
|
||||
status: 'normal' | 'finished'
|
||||
from_source: 'api' | 'console'
|
||||
from_end_user_id: string
|
||||
from_account_id: string
|
||||
// read_at: Date
|
||||
created_at: number
|
||||
model_config: {
|
||||
provider: string
|
||||
model_id: string
|
||||
configs: ModelConfigDetail
|
||||
}
|
||||
message: MessageContent
|
||||
}
|
||||
|
||||
export type CompletionConversationsResponse = {
|
||||
data: Array<CompletionConversationGeneralDetail>
|
||||
has_more: boolean
|
||||
limit: number
|
||||
total: number
|
||||
page: number
|
||||
}
|
||||
|
||||
export type CompletionConversationsRequest = {
|
||||
keyword: string
|
||||
start: string
|
||||
end: string
|
||||
annotation_status: string
|
||||
page: number
|
||||
limit: number // The default value is 20 and the range is 1-100
|
||||
}
|
||||
|
||||
export type ChatConversationGeneralDetail = Omit<
|
||||
CompletionConversationGeneralDetail,
|
||||
'message' | 'annotation'
|
||||
> & {
|
||||
summary: string
|
||||
message_count: number
|
||||
annotated: boolean
|
||||
}
|
||||
|
||||
export type ChatConversationsResponse = {
|
||||
data: Array<ChatConversationGeneralDetail>
|
||||
has_more: boolean
|
||||
limit: number
|
||||
total: number
|
||||
page: number
|
||||
}
|
||||
|
||||
export type ChatConversationsRequest = CompletionConversationsRequest & { message_count: number }
|
||||
|
||||
export type ChatConversationFullDetailResponse = Omit<
|
||||
CompletionConversationGeneralDetail,
|
||||
'message' | 'model_config'
|
||||
> & {
|
||||
message_count: number
|
||||
model_config: {
|
||||
provider: string
|
||||
model_id: string
|
||||
configs: ModelConfigDetail
|
||||
model: LogModelConfig
|
||||
}
|
||||
}
|
||||
|
||||
export type ChatMessagesRequest = {
|
||||
conversation_id: string
|
||||
first_id?: string
|
||||
@@ -230,30 +109,10 @@ export type TriggerMetadata = {
|
||||
icon_dark?: string | null
|
||||
}
|
||||
|
||||
type WorkflowLogDetails = {
|
||||
export type WorkflowLogDetails = {
|
||||
trigger_metadata?: TriggerMetadata
|
||||
}
|
||||
|
||||
export type WorkflowRunDetail = {
|
||||
id: string
|
||||
version: string
|
||||
status:
|
||||
| 'scheduled'
|
||||
| 'running'
|
||||
| 'succeeded'
|
||||
| 'failed'
|
||||
| 'stopped'
|
||||
| 'partial-succeeded'
|
||||
| 'paused'
|
||||
error?: string
|
||||
triggered_from?: WorkflowRunTriggeredFrom
|
||||
elapsed_time: number
|
||||
total_tokens: number
|
||||
total_price: number
|
||||
currency: string
|
||||
total_steps: number
|
||||
finished_at: number
|
||||
}
|
||||
type AccountInfo = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -265,24 +124,6 @@ type EndUserInfo = {
|
||||
is_anonymous: boolean
|
||||
session_id: string
|
||||
}
|
||||
export type WorkflowAppLogDetail = {
|
||||
id: string
|
||||
workflow_run: WorkflowRunDetail
|
||||
details?: WorkflowLogDetails
|
||||
created_from: 'service-api' | 'web-app' | 'explore'
|
||||
created_by_role: 'account' | 'end_user'
|
||||
created_by_account?: AccountInfo
|
||||
created_by_end_user?: EndUserInfo
|
||||
created_at: number
|
||||
read_at?: number
|
||||
}
|
||||
export type WorkflowLogsResponse = {
|
||||
data: Array<WorkflowAppLogDetail>
|
||||
has_more: boolean
|
||||
limit: number
|
||||
total: number
|
||||
page: number
|
||||
}
|
||||
export type WorkflowRunDetailResponse = {
|
||||
id: string
|
||||
version: string
|
||||
@@ -371,24 +212,3 @@ export type AgentLogDetailResponse = {
|
||||
iterations: AgentIteration[]
|
||||
files: AgentLogFile[]
|
||||
}
|
||||
|
||||
type PauseType =
|
||||
| {
|
||||
type: 'human_input'
|
||||
form_id: string
|
||||
backstage_input_url: string
|
||||
}
|
||||
| {
|
||||
type: 'breakpoint'
|
||||
}
|
||||
|
||||
type PauseDetail = {
|
||||
node_id: string
|
||||
node_title: string
|
||||
pause_type: PauseType
|
||||
}
|
||||
|
||||
export type WorkflowPausedDetailsResponse = {
|
||||
paused_at: string
|
||||
paused_nodes: PauseDetail[]
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import type {
|
||||
ChatConversationFullDetailResponse,
|
||||
ChatConversationsRequest,
|
||||
ChatConversationsResponse,
|
||||
CompletionConversationFullDetailResponse,
|
||||
CompletionConversationsRequest,
|
||||
CompletionConversationsResponse,
|
||||
WorkflowLogsResponse,
|
||||
WorkflowPausedDetailsResponse,
|
||||
} from '@/models/log'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { consoleClient } from '@/service/console'
|
||||
import { get } from './base'
|
||||
|
||||
const NAME_SPACE = 'log'
|
||||
|
||||
// ============ Annotations Count ============
|
||||
|
||||
export const useAnnotationsCount = (appId: string) => {
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'annotations-count', appId],
|
||||
queryFn: () =>
|
||||
consoleClient.apps.byAppId.annotations.count.get({
|
||||
params: {
|
||||
app_id: appId,
|
||||
},
|
||||
}),
|
||||
enabled: !!appId,
|
||||
})
|
||||
}
|
||||
|
||||
// ============ Chat Conversations ============
|
||||
|
||||
type ChatConversationsParams = {
|
||||
appId: string
|
||||
params?: Partial<ChatConversationsRequest>
|
||||
}
|
||||
|
||||
export const useChatConversations = ({ appId, params }: ChatConversationsParams) => {
|
||||
return useQuery<ChatConversationsResponse>({
|
||||
queryKey: [NAME_SPACE, 'chat-conversations', appId, params],
|
||||
queryFn: () => get<ChatConversationsResponse>(`/apps/${appId}/chat-conversations`, { params }),
|
||||
enabled: !!appId,
|
||||
})
|
||||
}
|
||||
|
||||
// ============ Completion Conversations ============
|
||||
|
||||
type CompletionConversationsParams = {
|
||||
appId: string
|
||||
params?: Partial<CompletionConversationsRequest>
|
||||
}
|
||||
|
||||
export const useCompletionConversations = ({ appId, params }: CompletionConversationsParams) => {
|
||||
return useQuery<CompletionConversationsResponse>({
|
||||
queryKey: [NAME_SPACE, 'completion-conversations', appId, params],
|
||||
queryFn: () =>
|
||||
get<CompletionConversationsResponse>(`/apps/${appId}/completion-conversations`, { params }),
|
||||
enabled: !!appId,
|
||||
})
|
||||
}
|
||||
|
||||
// ============ Chat Conversation Detail ============
|
||||
|
||||
export const useChatConversationDetail = (appId?: string, conversationId?: string) => {
|
||||
return useQuery<ChatConversationFullDetailResponse>({
|
||||
queryKey: [NAME_SPACE, 'chat-conversation-detail', appId, conversationId],
|
||||
queryFn: () =>
|
||||
get<ChatConversationFullDetailResponse>(
|
||||
`/apps/${appId}/chat-conversations/${conversationId}`,
|
||||
),
|
||||
enabled: !!appId && !!conversationId,
|
||||
})
|
||||
}
|
||||
|
||||
// ============ Completion Conversation Detail ============
|
||||
|
||||
export const useCompletionConversationDetail = (appId?: string, conversationId?: string) => {
|
||||
return useQuery<CompletionConversationFullDetailResponse>({
|
||||
queryKey: [NAME_SPACE, 'completion-conversation-detail', appId, conversationId],
|
||||
queryFn: () =>
|
||||
get<CompletionConversationFullDetailResponse>(
|
||||
`/apps/${appId}/completion-conversations/${conversationId}`,
|
||||
),
|
||||
enabled: !!appId && !!conversationId,
|
||||
})
|
||||
}
|
||||
|
||||
// ============ Workflow Logs ============
|
||||
|
||||
type WorkflowLogsParams = {
|
||||
appId: string
|
||||
params?: Record<string, string | number | boolean | undefined>
|
||||
}
|
||||
|
||||
export const useWorkflowLogs = ({ appId, params }: WorkflowLogsParams) => {
|
||||
return useQuery<WorkflowLogsResponse>({
|
||||
queryKey: [NAME_SPACE, 'workflow-logs', appId, params],
|
||||
queryFn: () => get<WorkflowLogsResponse>(`/apps/${appId}/workflow-app-logs`, { params }),
|
||||
enabled: !!appId,
|
||||
})
|
||||
}
|
||||
|
||||
// ============ Workflow Pause Details ============
|
||||
|
||||
type WorkflowPausedDetailsParams = {
|
||||
workflowRunId: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export const useWorkflowPausedDetails = ({
|
||||
workflowRunId,
|
||||
enabled = true,
|
||||
}: WorkflowPausedDetailsParams) => {
|
||||
return useQuery<WorkflowPausedDetailsResponse>({
|
||||
queryKey: [NAME_SPACE, 'workflow-paused-details', workflowRunId],
|
||||
queryFn: () => get<WorkflowPausedDetailsResponse>(`/workflow/${workflowRunId}/pause-details`),
|
||||
enabled: enabled && !!workflowRunId,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user