mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 02:43:49 +08:00
refactor(knowledge-fs): decompose overview page
This commit is contained in:
parent
8780803f2d
commit
d3f59d8458
@ -1,4 +1,5 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react'
|
||||
import type { Getter } from 'jotai'
|
||||
import { act, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import dayjs from 'dayjs'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
@ -172,6 +173,11 @@ const systemFeaturesState = vi.hoisted(() => ({
|
||||
uploadAvailableAtom: Symbol('knowledgeFsUploadEnabledAtom'),
|
||||
}))
|
||||
|
||||
const jotaiQueryMocks = vi.hoisted(() => ({
|
||||
bump: undefined as undefined | (() => void),
|
||||
refetchActivity: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('echarts-for-react', () => ({
|
||||
default: ({ option }: { option: unknown }) => {
|
||||
chartOptions.current = option
|
||||
@ -202,6 +208,45 @@ vi.mock('jotai', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('jotai-tanstack-query', async () => {
|
||||
const { atom, getDefaultStore } = await import('jotai/vanilla')
|
||||
const revisionAtom = atom(0)
|
||||
jotaiQueryMocks.bump = () =>
|
||||
getDefaultStore().set(revisionAtom, (revision: number) => revision + 1)
|
||||
|
||||
return {
|
||||
atomWithInfiniteQuery: (getOptions: (get: Getter) => { queryKey: string[] }) =>
|
||||
atom((get) => {
|
||||
get(revisionAtom)
|
||||
getOptions(get)
|
||||
return {
|
||||
data: { pages: [{ data: queryData.tasks, next_cursor: null }] },
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isError: tasksQueryState.isError,
|
||||
isFetchingNextPage: false,
|
||||
isPending: tasksQueryState.isPending,
|
||||
isRefetching: tasksQueryState.isRefetching,
|
||||
refetch: tasksQueryState.refetch,
|
||||
}
|
||||
}),
|
||||
atomWithQuery: (getOptions: (get: Getter) => { input?: unknown; queryKey: string[] }) =>
|
||||
atom((get) => {
|
||||
get(revisionAtom)
|
||||
const options = getOptions(get)
|
||||
const name = options.queryKey[0] as keyof typeof overviewQueryState
|
||||
const state = overviewQueryState[name]
|
||||
const skipped = typeof options.input === 'symbol'
|
||||
return {
|
||||
data: state.isError || skipped ? undefined : queryData[name],
|
||||
...state,
|
||||
isRefetching: state.isFetching,
|
||||
refetch: name === 'activity' ? jotaiQueryMocks.refetchActivity : vi.fn(),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMembers: () => ({
|
||||
data: {
|
||||
@ -307,6 +352,18 @@ vi.mock('@/service/client', () => {
|
||||
}
|
||||
})
|
||||
|
||||
function renderOverviewWithNuqs(...args: Parameters<typeof renderWithNuqs>) {
|
||||
jotaiQueryMocks.bump?.()
|
||||
const rendered = renderWithNuqs(...args)
|
||||
return {
|
||||
...rendered,
|
||||
rerender: (ui: Parameters<typeof rendered.rerender>[0]) => {
|
||||
act(() => jotaiQueryMocks.bump?.())
|
||||
rendered.rerender(ui)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('KnowledgeOverviewPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -357,7 +414,9 @@ describe('KnowledgeOverviewPage', () => {
|
||||
|
||||
it('refreshes the metrics query when a user switches the overview range', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onUrlUpdate } = renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
const { onUrlUpdate } = renderOverviewWithNuqs(
|
||||
<KnowledgeOverviewPage knowledgeSpaceId="space-1" />,
|
||||
)
|
||||
|
||||
const sevenDayRadio = screen.getByRole('radio', {
|
||||
name: 'dataset.newKnowledge.overview.sevenDays',
|
||||
@ -387,7 +446,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
})
|
||||
|
||||
it('restores the selected overview range from the URL', () => {
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />, {
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />, {
|
||||
searchParams: '?window=30d',
|
||||
})
|
||||
|
||||
@ -411,7 +470,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
})
|
||||
|
||||
it('renders ratio fields from the API contract as percentages', () => {
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
expect(screen.getByText('84%')).toBeInTheDocument()
|
||||
expect(screen.getByText('+100%')).toBeInTheDocument()
|
||||
@ -426,7 +485,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
uploads: 1,
|
||||
}
|
||||
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
const inventoryBar = screen.getByLabelText('dataset.newKnowledge.overview.sources')
|
||||
expect(inventoryBar.children).toHaveLength(1)
|
||||
@ -445,7 +504,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
},
|
||||
]
|
||||
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
expect(screen.getByText('84%')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('query outcomes chart')).toBeInTheDocument()
|
||||
@ -461,7 +520,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
},
|
||||
]
|
||||
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
const options = chartOptions.current as {
|
||||
series: Array<{ smooth: boolean }>
|
||||
@ -475,7 +534,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
})
|
||||
|
||||
it('shows the member who performed an activity instead of a system placeholder', () => {
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
expect(screen.getByText('Ada')).toBeInTheDocument()
|
||||
expect(screen.queryByText('dataset.newKnowledge.overview.system')).not.toBeInTheDocument()
|
||||
@ -486,7 +545,9 @@ describe('KnowledgeOverviewPage', () => {
|
||||
queryData.stats.documents = 0
|
||||
for (const state of Object.values(overviewQueryState)) state.isPending = true
|
||||
|
||||
const { rerender } = renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
const { rerender } = renderOverviewWithNuqs(
|
||||
<KnowledgeOverviewPage knowledgeSpaceId="space-1" />,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByRole('radiogroup', { name: 'dataset.newKnowledge.overview.timeRange' }),
|
||||
@ -525,7 +586,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
const user = userEvent.setup()
|
||||
overviewQueryState.activity.isError = true
|
||||
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
expect(screen.getByText('dataset.newKnowledge.tasksErrorDescription')).toBeInTheDocument()
|
||||
const activitySection = screen
|
||||
@ -559,7 +620,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
title: `Source ${index} has not synced in 7 days`,
|
||||
updated_at: '2026-07-29T08:00:00Z',
|
||||
}))
|
||||
const rendered = renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
const rendered = renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
expect(
|
||||
screen.getAllByText('dataset.newKnowledge.overview.attention.staleSource.title'),
|
||||
@ -614,7 +675,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
},
|
||||
]
|
||||
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
expect(
|
||||
screen.getByText('dataset.newKnowledge.overview.attention.modelReadiness.title'),
|
||||
@ -635,7 +696,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
|
||||
it('opens the complete activity view from recent activity', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
const whenHeader = screen.getByRole('columnheader', {
|
||||
name: 'dataset.newKnowledge.overview.when',
|
||||
})
|
||||
@ -664,7 +725,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
|
||||
it('enables the complete activity query only while its drawer is open', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
expect(infiniteOptionsMocks.activity.mock.lastCall?.[0]).toMatchObject({ enabled: false })
|
||||
|
||||
@ -677,7 +738,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
|
||||
it('restarts activity pagination when the date or operator filter changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'dataset.newKnowledge.overview.allActivity' }),
|
||||
)
|
||||
@ -741,7 +802,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
queryData.activity.data[0]!.occurred_at = new Date(Date.now() - 2 * 60 * 60_000).toISOString()
|
||||
queryData.activity.data[0]!.result = 'failure'
|
||||
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'dataset.newKnowledge.overview.allActivity' }),
|
||||
)
|
||||
@ -763,7 +824,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
}
|
||||
queryData.activity.data[0]!.result = 'success'
|
||||
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
const label = 'dataset.newKnowledge.qualityPage.question: How do permissions work? — research'
|
||||
expect(within(screen.getByRole('table')).getByText(label)).toBeInTheDocument()
|
||||
@ -780,7 +841,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
it('refreshes overview snapshots until they catch up with a completed background task', () => {
|
||||
queryData.tasks[0]!.state = 'running'
|
||||
queryData.tasks[0]!.updated_at = '2026-07-29T10:00:00Z'
|
||||
const rendered = renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
const rendered = renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
const statsRefreshInterval = () => {
|
||||
const options = queryOptionsMocks.stats.mock.lastCall?.[0] as {
|
||||
refetchInterval: (query: { state: { data: typeof queryData.stats } }) => false | number
|
||||
@ -803,7 +864,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
queryData.stats.source_count = 0
|
||||
queryData.stats.documents = 0
|
||||
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'dataset.newKnowledge.overview.noSources' }),
|
||||
@ -835,7 +896,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
queryData.stats.source_count = 0
|
||||
queryData.stats.documents = 0
|
||||
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
const connectSource = screen.getByRole('link', {
|
||||
name: 'dataset.newKnowledge.overview.connectSource',
|
||||
@ -858,7 +919,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
queryData.tasks[0]!.operation = 'document_upload'
|
||||
queryData.tasks[0]!.state = 'failed'
|
||||
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
const overviewTitle = screen.getByRole('heading', {
|
||||
name: 'dataset.newKnowledge.overviewTitle',
|
||||
@ -915,7 +976,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
},
|
||||
]
|
||||
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(
|
||||
@ -932,7 +993,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
permissionState.datasetKeys = ['dataset.acl.readonly']
|
||||
permissionState.workspaceKeys = ['dataset.acl.readonly']
|
||||
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'dataset.newKnowledge.overview.connectSource' }),
|
||||
@ -950,7 +1011,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
queryData.stats.documents = 0
|
||||
permissionState.datasetKeys = ['dataset.acl.readonly']
|
||||
permissionState.workspaceKeys = ['dataset.external.connect']
|
||||
const rendered = renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
const rendered = renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'dataset.newKnowledge.overview.connectSource' }),
|
||||
@ -975,7 +1036,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
overviewQueryState.attention.isError = true
|
||||
overviewQueryState.inventory.isError = true
|
||||
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
const healthSection = screen
|
||||
.getByRole('heading', { name: 'dataset.newKnowledge.overview.needsAttention' })
|
||||
@ -1005,7 +1066,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
queryData.stats.source_count = 0
|
||||
queryData.stats.documents = 1
|
||||
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'dataset.newKnowledge.overview.recentActivity' }),
|
||||
@ -1016,7 +1077,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
})
|
||||
|
||||
it('labels index coverage as slices rather than documents', () => {
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
expect(
|
||||
screen.getByText('dataset.newKnowledge.overview.indexedSlices:{"indexed":4,"total":5}'),
|
||||
@ -1037,7 +1098,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
queryData.tasks[0]!.source_id = 'source-1'
|
||||
queryData.tasks[0]!.state = 'running'
|
||||
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', {
|
||||
@ -1065,7 +1126,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
queryData.tasks[0]!.progress_total = 0
|
||||
queryData.tasks[0]!.state = 'queued'
|
||||
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
const progressbar = screen.getByRole('progressbar', {
|
||||
name: 'dataset.newKnowledge.overview.indexing',
|
||||
@ -1082,7 +1143,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
queryData.tasks[0]!.source_id = null
|
||||
queryData.tasks[0]!.state = 'running'
|
||||
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'dataset.newKnowledge.overview.indexing' }),
|
||||
@ -1094,7 +1155,7 @@ describe('KnowledgeOverviewPage', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
queryData.activity.data[0]!.occurred_at = '2026-07-29T08:05:00Z'
|
||||
|
||||
renderWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
renderOverviewWithNuqs(<KnowledgeOverviewPage knowledgeSpaceId="space-1" />)
|
||||
|
||||
expect(screen.getByText('59 minutes ago')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { atom, createStore, Provider, useAtomValue } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { overviewKnowledgeSpaceIdAtom, overviewWindowAtom } from '../state'
|
||||
import { OverviewStateBoundary } from '../state-boundary'
|
||||
|
||||
const parentValueAtom = atom('missing')
|
||||
|
||||
function OverviewInputs({ label }: { label: string }) {
|
||||
const knowledgeSpaceId = useAtomValue(overviewKnowledgeSpaceIdAtom)
|
||||
const window = useAtomValue(overviewWindowAtom)
|
||||
const parentValue = useAtomValue(parentValueAtom)
|
||||
return <p>{`${label}:${knowledgeSpaceId}:${window}:${parentValue}`}</p>
|
||||
}
|
||||
|
||||
function LocalSession() {
|
||||
const [count, setCount] = useState(0)
|
||||
return <button onClick={() => setCount((current) => current + 1)}>{count}</button>
|
||||
}
|
||||
|
||||
describe('OverviewStateBoundary', () => {
|
||||
it('isolates route inputs between sibling instances while observing the parent store', () => {
|
||||
const store = createStore()
|
||||
store.set(parentValueAtom, 'parent-visible')
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<OverviewStateBoundary knowledgeSpaceId="space-a" window="24h">
|
||||
<OverviewInputs label="first" />
|
||||
</OverviewStateBoundary>
|
||||
<OverviewStateBoundary knowledgeSpaceId="space-b" window="7d">
|
||||
<OverviewInputs label="second" />
|
||||
</OverviewStateBoundary>
|
||||
</Provider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('first:space-a:24h:parent-visible')).toBeInTheDocument()
|
||||
expect(screen.getByText('second:space-b:7d:parent-visible')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('resets owner-local sessions when the knowledge space identity changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const rendered = render(
|
||||
<OverviewStateBoundary knowledgeSpaceId="space-a" window="24h">
|
||||
<LocalSession />
|
||||
</OverviewStateBoundary>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '0' }))
|
||||
expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument()
|
||||
|
||||
act(() =>
|
||||
rendered.rerender(
|
||||
<OverviewStateBoundary knowledgeSpaceId="space-b" window="24h">
|
||||
<LocalSession />
|
||||
</OverviewStateBoundary>,
|
||||
),
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: '0' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -25,16 +25,26 @@ import {
|
||||
SelectItemText,
|
||||
SelectTrigger,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DatePicker from '@/app/components/base/date-and-time-picker/date-picker'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import { activityDatesForRange } from './overview-activity-types'
|
||||
import { OVERVIEW_REFRESH_INTERVAL } from './overview-format'
|
||||
import { EmptyInline, Panel, Skeleton } from './overview-panel'
|
||||
import {
|
||||
overviewActivityPreviewDataAtom,
|
||||
overviewActivityPreviewErrorAtom,
|
||||
overviewActivityPreviewPendingAtom,
|
||||
overviewActivityPreviewRefetchingAtom,
|
||||
overviewKnowledgeSpaceIdAtom,
|
||||
overviewShowEmptyModulesAtom,
|
||||
overviewShowIndexingAtom,
|
||||
retryOverviewActivityPreviewAtom,
|
||||
} from './state'
|
||||
|
||||
const ACTIVITY_RANGES: ActivityRange[] = ['today', '7d', '30d', '90d', 'all', 'custom']
|
||||
const ACTIVITY_PAGE_SIZE = 20
|
||||
@ -150,29 +160,17 @@ function ActivityActor({
|
||||
)
|
||||
}
|
||||
|
||||
function RecentActivity({
|
||||
activities,
|
||||
empty,
|
||||
error,
|
||||
indexing = false,
|
||||
loading,
|
||||
members,
|
||||
onOpenAll,
|
||||
onRetry,
|
||||
retrying,
|
||||
}: {
|
||||
activities: KnowledgeFsOverviewActivityResponse[]
|
||||
empty: boolean
|
||||
error: boolean
|
||||
indexing?: boolean
|
||||
loading: boolean
|
||||
onOpenAll: () => void
|
||||
onRetry: () => void
|
||||
retrying: boolean
|
||||
members: Member[]
|
||||
}) {
|
||||
function RecentActivity({ onOpenAll }: { onOpenAll: () => void }) {
|
||||
const { t, i18n } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const activities = useAtomValue(overviewActivityPreviewDataAtom)
|
||||
const empty = useAtomValue(overviewShowEmptyModulesAtom)
|
||||
const error = useAtomValue(overviewActivityPreviewErrorAtom)
|
||||
const indexing = useAtomValue(overviewShowIndexingAtom)
|
||||
const loading = useAtomValue(overviewActivityPreviewPendingAtom)
|
||||
const retrying = useAtomValue(overviewActivityPreviewRefetchingAtom)
|
||||
const retry = useSetAtom(retryOverviewActivityPreviewAtom)
|
||||
const members = useMembers().data?.accounts ?? []
|
||||
const formatWhen = (value: string) => {
|
||||
const timestamp = new Date(value)
|
||||
const elapsedMinutes = Math.max(0, Math.floor((Date.now() - timestamp.getTime()) / 60_000))
|
||||
@ -207,7 +205,7 @@ function RecentActivity({
|
||||
loading={retrying}
|
||||
size="small"
|
||||
variant="secondary"
|
||||
onClick={onRetry}
|
||||
onClick={retry}
|
||||
>
|
||||
{tCommon(($) => $['operation.retry'])}
|
||||
</Button>
|
||||
@ -315,51 +313,15 @@ function RecentActivity({
|
||||
)
|
||||
}
|
||||
|
||||
export function OverviewActivity({
|
||||
empty,
|
||||
hasActiveTasks,
|
||||
indexing,
|
||||
knowledgeSpaceId,
|
||||
}: {
|
||||
empty: boolean
|
||||
hasActiveTasks: boolean
|
||||
indexing: boolean
|
||||
knowledgeSpaceId: string
|
||||
}) {
|
||||
export function OverviewActivity() {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const membersQuery = useMembers()
|
||||
const previewQuery = useQuery(
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.activity.get.queryOptions({
|
||||
input: {
|
||||
params: { control_space_id: knowledgeSpaceId },
|
||||
query: { limit: 5 },
|
||||
},
|
||||
refetchInterval: hasActiveTasks ? OVERVIEW_REFRESH_INTERVAL : false,
|
||||
}),
|
||||
)
|
||||
const members = membersQuery.data?.accounts ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-3">
|
||||
<RecentActivity
|
||||
activities={previewQuery.data?.data ?? []}
|
||||
empty={empty}
|
||||
error={previewQuery.isError}
|
||||
indexing={indexing}
|
||||
loading={previewQuery.isPending}
|
||||
members={members}
|
||||
retrying={previewQuery.isRefetching}
|
||||
onOpenAll={() => setDrawerOpen(true)}
|
||||
onRetry={() => void previewQuery.refetch()}
|
||||
/>
|
||||
<RecentActivity onOpenAll={() => setDrawerOpen(true)} />
|
||||
</div>
|
||||
<ActivityDrawer
|
||||
knowledgeSpaceId={knowledgeSpaceId}
|
||||
members={members}
|
||||
open={drawerOpen}
|
||||
onOpenChange={setDrawerOpen}
|
||||
/>
|
||||
<ActivityDrawer open={drawerOpen} onOpenChange={setDrawerOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -440,19 +402,17 @@ function ActivityDateRangePicker({
|
||||
}
|
||||
|
||||
function ActivityDrawer({
|
||||
knowledgeSpaceId,
|
||||
members,
|
||||
onOpenChange,
|
||||
open,
|
||||
}: {
|
||||
knowledgeSpaceId: string
|
||||
members: Member[]
|
||||
onOpenChange: (open: boolean) => void
|
||||
open: boolean
|
||||
}) {
|
||||
const { t, i18n } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const { t: tActivityLog } = useTranslation('appLog')
|
||||
const knowledgeSpaceId = useAtomValue(overviewKnowledgeSpaceIdAtom)
|
||||
const members = useMembers().data?.accounts ?? []
|
||||
const [range, setRange] = useState<ActivityRange>('today')
|
||||
const [dates, setDates] = useState<ActivityDateRange>(() => activityDatesForRange('today'))
|
||||
const [operator, setOperator] = useState<ActivityOperator>('all')
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import type { KnowledgeFsOverviewAttentionResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Link from '@/next/link'
|
||||
@ -13,6 +14,13 @@ import {
|
||||
newKnowledgeSettingsPath,
|
||||
} from '../routes'
|
||||
import { EmptyInline, OverviewErrorInline, Panel, Skeleton } from './overview-panel'
|
||||
import {
|
||||
overviewAttentionDataAtom,
|
||||
overviewAttentionErrorAtom,
|
||||
overviewAttentionPendingAtom,
|
||||
overviewKnowledgeSpaceIdAtom,
|
||||
overviewShowEmptyModulesAtom,
|
||||
} from './state'
|
||||
|
||||
const ATTENTION_PAGE_SIZE = 4
|
||||
|
||||
@ -56,21 +64,14 @@ function attentionPresentation(
|
||||
return { title: issue.title }
|
||||
}
|
||||
|
||||
export function AttentionPanel({
|
||||
attention,
|
||||
empty,
|
||||
error,
|
||||
knowledgeSpaceId,
|
||||
loading,
|
||||
}: {
|
||||
attention: KnowledgeFsOverviewAttentionResponse[]
|
||||
empty: boolean
|
||||
error: boolean
|
||||
knowledgeSpaceId: string
|
||||
loading: boolean
|
||||
}) {
|
||||
export function AttentionPanel() {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const attention = useAtomValue(overviewAttentionDataAtom)
|
||||
const empty = useAtomValue(overviewShowEmptyModulesAtom)
|
||||
const error = useAtomValue(overviewAttentionErrorAtom)
|
||||
const knowledgeSpaceId = useAtomValue(overviewKnowledgeSpaceIdAtom)
|
||||
const loading = useAtomValue(overviewAttentionPendingAtom)
|
||||
const [issuePage, setIssuePage] = useState(0)
|
||||
// Dify owns product authorization; ignore responses cached or served by an older backend that
|
||||
// still contain the retired KnowledgeFS-local permission readiness rule.
|
||||
|
||||
@ -1,36 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import type { KnowledgeFsOverviewInventoryResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { compactNumber } from './overview-format'
|
||||
import { EmptyInline, OverviewErrorInline, Panel, Skeleton } from './overview-panel'
|
||||
import {
|
||||
overviewInventoryDataAtom,
|
||||
overviewInventoryErrorAtom,
|
||||
overviewInventoryPendingAtom,
|
||||
overviewShowEmptyModulesAtom,
|
||||
overviewShowIndexingAtom,
|
||||
} from './state'
|
||||
|
||||
export function InventoryPanel({
|
||||
empty,
|
||||
error,
|
||||
indexing = false,
|
||||
inventory,
|
||||
loading,
|
||||
}: {
|
||||
empty: boolean
|
||||
error: boolean
|
||||
indexing?: boolean
|
||||
inventory:
|
||||
| {
|
||||
graph_entities: { added_last_7d: number; total: number }
|
||||
graph_relations: { added_last_7d: number; total: number }
|
||||
index_coverage: { indexed: number; percentage: number; total: number }
|
||||
source_categories: {
|
||||
crawl: number
|
||||
online_documents: number
|
||||
online_drives: number
|
||||
uploads: number
|
||||
}
|
||||
}
|
||||
| undefined
|
||||
loading: boolean
|
||||
}) {
|
||||
export function InventoryPanel() {
|
||||
const { t } = useTranslation('dataset')
|
||||
const empty = useAtomValue(overviewShowEmptyModulesAtom)
|
||||
const error = useAtomValue(overviewInventoryErrorAtom)
|
||||
const indexing = useAtomValue(overviewShowIndexingAtom)
|
||||
const inventory: KnowledgeFsOverviewInventoryResponse | undefined =
|
||||
useAtomValue(overviewInventoryDataAtom)
|
||||
const loading = useAtomValue(overviewInventoryPendingAtom)
|
||||
const categories = inventory
|
||||
? [
|
||||
{
|
||||
|
||||
@ -1,13 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import type { KnowledgeFsOverviewQueryOutcomeBucketResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import { changeLabel, compactNumber, formatDuration } from './overview-format'
|
||||
import { EmptyInline, OverviewErrorInline, Panel, Skeleton } from './overview-panel'
|
||||
import { buildQueryOutcomesChartOptions } from './query-outcomes-chart-options'
|
||||
import {
|
||||
overviewOutcomesDataAtom,
|
||||
overviewOutcomesErrorAtom,
|
||||
overviewOutcomesPendingAtom,
|
||||
overviewShowEmptyModulesAtom,
|
||||
overviewStatsDataAtom,
|
||||
overviewStatsPendingAtom,
|
||||
} from './state'
|
||||
|
||||
export function MetricCard({
|
||||
change,
|
||||
@ -83,22 +92,70 @@ export function MetricCard({
|
||||
)
|
||||
}
|
||||
|
||||
export function QueryOutcomesChart({
|
||||
buckets,
|
||||
empty,
|
||||
error,
|
||||
loading,
|
||||
}: {
|
||||
buckets: KnowledgeFsOverviewQueryOutcomeBucketResponse[]
|
||||
empty: boolean
|
||||
error: boolean
|
||||
loading: boolean
|
||||
}) {
|
||||
export function OverviewMetrics() {
|
||||
const empty = useAtomValue(overviewShowEmptyModulesAtom)
|
||||
const loading = useAtomValue(overviewStatsPendingAtom)
|
||||
const stats = useAtomValue(overviewStatsDataAtom)
|
||||
const { t } = useTranslation('dataset')
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<MetricCard
|
||||
empty={empty}
|
||||
loading={loading}
|
||||
title={t(($) => $['newKnowledge.overview.queries'])}
|
||||
help={t(($) => $['newKnowledge.overview.queriesHelp'])}
|
||||
value={stats ? compactNumber(stats.queries.value) : '—'}
|
||||
change={
|
||||
stats
|
||||
? changeLabel(
|
||||
stats.queries.change_rate === null ? null : stats.queries.change_rate * 100,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
empty={empty}
|
||||
loading={loading}
|
||||
title={t(($) => $['newKnowledge.overview.answerRate'])}
|
||||
help={t(($) => $['newKnowledge.overview.answerRateHelp'])}
|
||||
value={stats ? `${Math.round(stats.answer_rate.value * 100)}%` : '—'}
|
||||
change={stats ? changeLabel(stats.answer_rate.change_percentage_points, 'pp') : undefined}
|
||||
/>
|
||||
<MetricCard
|
||||
empty={empty}
|
||||
loading={loading}
|
||||
title={t(($) => $['newKnowledge.overview.documents'])}
|
||||
value={stats ? compactNumber(stats.documents) : '—'}
|
||||
/>
|
||||
<MetricCard
|
||||
empty={empty}
|
||||
loading={loading}
|
||||
title={t(($) => $['newKnowledge.overview.linkedApps'])}
|
||||
value={stats ? compactNumber(stats.linked_apps) : '—'}
|
||||
/>
|
||||
<MetricCard
|
||||
empty={empty}
|
||||
loading={loading}
|
||||
title={t(($) => $['newKnowledge.overview.freshness'])}
|
||||
help={t(($) => $['newKnowledge.overview.freshnessHelp'])}
|
||||
value={formatDuration(stats?.freshness_seconds)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function QueryOutcomesChart() {
|
||||
const outcomes = useAtomValue(overviewOutcomesDataAtom)
|
||||
const empty = useAtomValue(overviewShowEmptyModulesAtom)
|
||||
const error = useAtomValue(overviewOutcomesErrorAtom)
|
||||
const loading = useAtomValue(overviewOutcomesPendingAtom)
|
||||
const buckets = outcomes?.buckets
|
||||
const { t, i18n } = useTranslation('dataset')
|
||||
const chartOptions = useMemo(
|
||||
() =>
|
||||
buildQueryOutcomesChartOptions({
|
||||
buckets,
|
||||
buckets: buckets ?? [],
|
||||
labels: {
|
||||
answered: t(($) => $['newKnowledge.overview.answered']),
|
||||
lowConfidence: t(($) => $['newKnowledge.overview.lowConfidence']),
|
||||
@ -175,7 +232,7 @@ export function QueryOutcomesChart({
|
||||
<Skeleton key={key} className="h-3" style={{ width: `${width}%` }} />
|
||||
))}
|
||||
</div>
|
||||
) : buckets.length ? (
|
||||
) : buckets?.length ? (
|
||||
<>
|
||||
<p className="sr-only">
|
||||
{t(($) => $['newKnowledge.overview.queryOutcomes'])}: {buckets.length}
|
||||
|
||||
@ -1,30 +1,93 @@
|
||||
'use client'
|
||||
|
||||
import type { KnowledgeFsBackgroundTaskResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
|
||||
import type { MouseEvent, ReactNode } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
datasetDefaultPermissionKeysAtom,
|
||||
workspacePermissionKeysAtom,
|
||||
} from '@/context/permission-state'
|
||||
import { knowledgeFsUploadEnabledAtom } from '@/features/system-features/state'
|
||||
import Link from '@/next/link'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { DatasetACLPermission, hasPermission } from '@/utils/permission'
|
||||
import { newKnowledgeAddSourcePath, newKnowledgeDocumentsPath } from '../routes'
|
||||
import {
|
||||
overviewEmptyAtom,
|
||||
overviewFailedFirstSourceTaskAtom,
|
||||
overviewIndexingSourceNameAtom,
|
||||
overviewIndexingTaskAtom,
|
||||
overviewKnowledgeSpaceIdAtom,
|
||||
overviewShowIndexingAtom,
|
||||
refreshOverviewBackgroundTasksAtom,
|
||||
} from './state'
|
||||
|
||||
export function Onboarding({
|
||||
canConnectSource,
|
||||
canUpload,
|
||||
indexingTask,
|
||||
indexingSourceName,
|
||||
knowledgeSpaceId,
|
||||
}: {
|
||||
canConnectSource: boolean
|
||||
canUpload: boolean
|
||||
indexingTask?: KnowledgeFsBackgroundTaskResponse
|
||||
indexingSourceName?: string
|
||||
knowledgeSpaceId: string
|
||||
}) {
|
||||
export function OverviewOnboarding() {
|
||||
const empty = useAtomValue(overviewEmptyAtom)
|
||||
const showIndexing = useAtomValue(overviewShowIndexingAtom)
|
||||
let content: ReactNode = null
|
||||
if (showIndexing) content = <IndexingProgress />
|
||||
else if (empty) content = <EmptyKnowledgeOnboarding />
|
||||
return content ? <div className="mt-3">{content}</div> : null
|
||||
}
|
||||
|
||||
function IndexingProgress() {
|
||||
const { t } = useTranslation('dataset')
|
||||
const indexingTask = useAtomValue(overviewIndexingTaskAtom)
|
||||
const indexingSourceName = useAtomValue(overviewIndexingSourceNameAtom)
|
||||
if (!indexingTask) return null
|
||||
|
||||
const progressKnown = indexingTask.progress_total > 0
|
||||
return (
|
||||
<section className="flex h-29.75 flex-col rounded-xl bg-background-section p-4">
|
||||
<h2 className="text-[18px] leading-[1.2] font-semibold text-text-primary">
|
||||
{indexingSourceName
|
||||
? t(($) => $['newKnowledge.overview.indexingSource'], { source: indexingSourceName })
|
||||
: t(($) => $['newKnowledge.overview.indexing'])}
|
||||
</h2>
|
||||
<p className="mt-1 text-[13px] leading-4 font-normal text-text-primary">
|
||||
{t(($) => $['newKnowledge.overview.indexingConnectedDescription'])}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={t(($) => $['newKnowledge.overview.indexing'])}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={progressKnown ? indexingTask.progress_total : undefined}
|
||||
aria-valuenow={progressKnown ? indexingTask.progress_completed : undefined}
|
||||
className="h-2 overflow-hidden rounded-full bg-util-colors-gray-gray-200"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-components-progress-bar-progress-solid"
|
||||
style={{ width: `${indexingTask.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2.5 system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['newKnowledge.overview.indexedDocuments'], {
|
||||
indexed: indexingTask.progress_completed,
|
||||
total: indexingTask.progress_total,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyKnowledgeOnboarding() {
|
||||
const { t } = useTranslation('dataset')
|
||||
const knowledgeSpaceId = useAtomValue(overviewKnowledgeSpaceIdAtom)
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const datasetDefaultPermissionKeys = useAtomValue(datasetDefaultPermissionKeysAtom)
|
||||
const uploadAvailable = useAtomValue(knowledgeFsUploadEnabledAtom)
|
||||
const [pendingAction, setPendingAction] = useState<'source' | 'upload'>()
|
||||
const canConnectSource = hasPermission(workspacePermissionKeys, 'dataset.external.connect')
|
||||
const canUpload =
|
||||
uploadAvailable && hasPermission(datasetDefaultPermissionKeys, DatasetACLPermission.Edit)
|
||||
const actionCount = Number(canConnectSource) + Number(canUpload)
|
||||
const description = canConnectSource
|
||||
? canUpload
|
||||
@ -33,43 +96,22 @@ export function Onboarding({
|
||||
: canUpload
|
||||
? t(($) => $['newKnowledge.uploadFilesDescription'])
|
||||
: t(($) => $['newKnowledge.overview.readOnlyDescription'])
|
||||
if (indexingTask) {
|
||||
const progressKnown = indexingTask.progress_total > 0
|
||||
return (
|
||||
<section className="flex h-29.75 flex-col rounded-xl bg-background-section p-4">
|
||||
<h2 className="text-[18px] leading-[1.2] font-semibold text-text-primary">
|
||||
{indexingSourceName
|
||||
? t(($) => $['newKnowledge.overview.indexingSource'], {
|
||||
source: indexingSourceName,
|
||||
})
|
||||
: t(($) => $['newKnowledge.overview.indexing'])}
|
||||
</h2>
|
||||
<p className="mt-1 text-[13px] leading-4 font-normal text-text-primary">
|
||||
{t(($) => $['newKnowledge.overview.indexingConnectedDescription'])}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={t(($) => $['newKnowledge.overview.indexing'])}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={progressKnown ? indexingTask.progress_total : undefined}
|
||||
aria-valuenow={progressKnown ? indexingTask.progress_completed : undefined}
|
||||
className="h-2 overflow-hidden rounded-full bg-util-colors-gray-gray-200"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-components-progress-bar-progress-solid"
|
||||
style={{ width: `${indexingTask.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2.5 system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['newKnowledge.overview.indexedDocuments'], {
|
||||
indexed: indexingTask.progress_completed,
|
||||
total: indexingTask.progress_total,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
const beginNavigation = (action: 'source' | 'upload', event: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
)
|
||||
return
|
||||
if (pendingAction !== undefined) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
setPendingAction(action)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -108,22 +150,7 @@ export function Onboarding({
|
||||
)}
|
||||
href={newKnowledgeAddSourcePath(knowledgeSpaceId)}
|
||||
tabIndex={pendingAction === undefined ? undefined : -1}
|
||||
onClick={(event) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
)
|
||||
return
|
||||
if (pendingAction !== undefined) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
setPendingAction('source')
|
||||
}}
|
||||
onClick={(event) => beginNavigation('source', event)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
@ -153,22 +180,7 @@ export function Onboarding({
|
||||
)}
|
||||
href={`${newKnowledgeDocumentsPath(knowledgeSpaceId)}?upload=1`}
|
||||
tabIndex={pendingAction === undefined ? undefined : -1}
|
||||
onClick={(event) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
)
|
||||
return
|
||||
if (pendingAction !== undefined) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
setPendingAction('upload')
|
||||
}}
|
||||
onClick={(event) => beginNavigation('upload', event)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
@ -193,16 +205,21 @@ export function Onboarding({
|
||||
)
|
||||
}
|
||||
|
||||
export function FirstSourceTaskFailureBanner({
|
||||
export function FirstSourceTaskFailureBanner() {
|
||||
const empty = useAtomValue(overviewEmptyAtom)
|
||||
const failedTask = useAtomValue(overviewFailedFirstSourceTaskAtom)
|
||||
if (!empty || !failedTask) return null
|
||||
return <FirstSourceTaskFailureSession key={failedTask.id} failedTask={failedTask} />
|
||||
}
|
||||
|
||||
function FirstSourceTaskFailureSession({
|
||||
failedTask,
|
||||
knowledgeSpaceId,
|
||||
onRetryTask,
|
||||
}: {
|
||||
failedTask: KnowledgeFsBackgroundTaskResponse
|
||||
knowledgeSpaceId: string
|
||||
onRetryTask: () => Promise<unknown>
|
||||
}) {
|
||||
const { t } = useTranslation('dataset')
|
||||
const knowledgeSpaceId = useAtomValue(overviewKnowledgeSpaceIdAtom)
|
||||
const refreshBackgroundTasks = useSetAtom(refreshOverviewBackgroundTasksAtom)
|
||||
const retryTaskMutation = useMutation(
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.backgroundTasks.byTaskKind.byTaskId.retry.post.mutationOptions(),
|
||||
)
|
||||
@ -212,7 +229,6 @@ export function FirstSourceTaskFailureBanner({
|
||||
: t(($) => $['newKnowledge.addSourceFailed'])
|
||||
const retryFailedTask = async () => {
|
||||
if (!failedTask.can_retry || retryTaskMutation.isPending) return
|
||||
|
||||
try {
|
||||
await retryTaskMutation.mutateAsync({
|
||||
params: {
|
||||
@ -221,7 +237,7 @@ export function FirstSourceTaskFailureBanner({
|
||||
task_kind: failedTask.task_kind,
|
||||
},
|
||||
})
|
||||
await onRetryTask()
|
||||
await refreshBackgroundTasks()
|
||||
} catch {
|
||||
// Mutation state keeps the retry feedback visible.
|
||||
}
|
||||
|
||||
@ -1,325 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import type { KnowledgeFsBackgroundTaskResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
|
||||
import type { OverviewWindow } from './state'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
|
||||
import { skipToken, useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
datasetDefaultPermissionKeysAtom,
|
||||
workspacePermissionKeysAtom,
|
||||
} from '@/context/permission-state'
|
||||
import { knowledgeFsUploadEnabledAtom } from '@/features/system-features/state'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { DatasetACLPermission, hasPermission } from '@/utils/permission'
|
||||
import { KnowledgeModelReadinessBanner } from '../components/knowledge-model-readiness-banner'
|
||||
import { OverviewActivity } from './overview-activity'
|
||||
import { AttentionPanel } from './overview-attention'
|
||||
import {
|
||||
changeLabel,
|
||||
compactNumber,
|
||||
formatDuration,
|
||||
OVERVIEW_REFRESH_INTERVAL,
|
||||
overviewRefreshInterval,
|
||||
} from './overview-format'
|
||||
import { InventoryPanel } from './overview-inventory'
|
||||
import { MetricCard, QueryOutcomesChart } from './overview-metrics'
|
||||
import { FirstSourceTaskFailureBanner, Onboarding } from './overview-onboarding'
|
||||
|
||||
type OverviewWindow = '24h' | '7d' | '30d'
|
||||
import { OverviewMetrics, QueryOutcomesChart } from './overview-metrics'
|
||||
import { FirstSourceTaskFailureBanner, OverviewOnboarding } from './overview-onboarding'
|
||||
import {
|
||||
overviewEmptyAtom,
|
||||
overviewFirstLoadFailedAtom,
|
||||
overviewKnowledgeSpaceIdAtom,
|
||||
overviewPageLoadingAtom,
|
||||
overviewShowEmptyModulesAtom,
|
||||
overviewShowIndexingAtom,
|
||||
overviewWindowAtom,
|
||||
retryOverviewSnapshotsAtom,
|
||||
} from './state'
|
||||
import { OverviewStateBoundary } from './state-boundary'
|
||||
|
||||
const WINDOWS: OverviewWindow[] = ['24h', '7d', '30d']
|
||||
const ACTIVE_TASK_STATES = new Set<KnowledgeFsBackgroundTaskResponse['state']>([
|
||||
'queued',
|
||||
'running',
|
||||
])
|
||||
const TASK_PAGE_SIZE = 20
|
||||
const overviewWindowParser = parseAsStringLiteral(WINDOWS)
|
||||
.withDefault('24h')
|
||||
.withOptions({ history: 'push' })
|
||||
|
||||
function isFirstSourceTask(task: KnowledgeFsBackgroundTaskResponse) {
|
||||
export function KnowledgeOverviewPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) {
|
||||
const [window, setWindow] = useQueryState('window', overviewWindowParser)
|
||||
|
||||
return (
|
||||
task.operation === 'document_processing' ||
|
||||
task.operation === 'document_upload' ||
|
||||
task.operation.includes('import')
|
||||
<OverviewStateBoundary knowledgeSpaceId={knowledgeSpaceId} window={window}>
|
||||
<KnowledgeOverviewContent window={window} onWindowChange={(value) => void setWindow(value)} />
|
||||
</OverviewStateBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export function KnowledgeOverviewPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const datasetDefaultPermissionKeys = useAtomValue(datasetDefaultPermissionKeysAtom)
|
||||
const uploadAvailable = useAtomValue(knowledgeFsUploadEnabledAtom)
|
||||
const canConnectSource = hasPermission(workspacePermissionKeys, 'dataset.external.connect')
|
||||
const canUpload =
|
||||
uploadAvailable && hasPermission(datasetDefaultPermissionKeys, DatasetACLPermission.Edit)
|
||||
const [window, setWindow] = useQueryState('window', overviewWindowParser)
|
||||
const tasksQuery = useInfiniteQuery(
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.backgroundTasks.get.infiniteOptions({
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor,
|
||||
initialPageParam: null as string | null,
|
||||
input: (pageParam) => ({
|
||||
params: { control_space_id: knowledgeSpaceId },
|
||||
query: {
|
||||
...(typeof pageParam === 'string' ? { cursor: pageParam } : {}),
|
||||
limit: TASK_PAGE_SIZE,
|
||||
},
|
||||
}),
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.pages.some((page) =>
|
||||
page.data.some((task) => ACTIVE_TASK_STATES.has(task.state)),
|
||||
)
|
||||
? OVERVIEW_REFRESH_INTERVAL
|
||||
: false,
|
||||
}),
|
||||
)
|
||||
const tasks = tasksQuery.data?.pages.flatMap((page) => page.data) ?? []
|
||||
const hasActiveTasks = tasks.some((task) => ACTIVE_TASK_STATES.has(task.state))
|
||||
const latestTaskUpdatedAt = tasks.reduce<number | undefined>((latest, task) => {
|
||||
const updatedAt = Date.parse(task.updated_at)
|
||||
if (!Number.isFinite(updatedAt)) return latest
|
||||
return latest === undefined || updatedAt > latest ? updatedAt : latest
|
||||
}, undefined)
|
||||
const statsQuery = useQuery(
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.stats.get.queryOptions({
|
||||
input: {
|
||||
params: { control_space_id: knowledgeSpaceId },
|
||||
query: { window },
|
||||
},
|
||||
refetchInterval: (query) =>
|
||||
overviewRefreshInterval({
|
||||
generatedAt: query.state.data?.generated_at,
|
||||
hasActiveTasks,
|
||||
latestTaskUpdatedAt,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const outcomesQuery = useQuery(
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.queryOutcomes.get.queryOptions({
|
||||
input: {
|
||||
params: { control_space_id: knowledgeSpaceId },
|
||||
query: { window },
|
||||
},
|
||||
refetchInterval: (query) =>
|
||||
overviewRefreshInterval({
|
||||
generatedAt: query.state.data?.generated_at,
|
||||
hasActiveTasks,
|
||||
latestTaskUpdatedAt,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const inventoryQuery = useQuery(
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.inventory.get.queryOptions({
|
||||
input: { params: { control_space_id: knowledgeSpaceId } },
|
||||
refetchInterval: (query) =>
|
||||
overviewRefreshInterval({
|
||||
generatedAt: query.state.data?.generated_at,
|
||||
hasActiveTasks,
|
||||
latestTaskUpdatedAt,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const attentionQuery = useQuery(
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.attention.get.queryOptions({
|
||||
input: {
|
||||
params: { control_space_id: knowledgeSpaceId },
|
||||
query: { include_dismissed: false, limit: 100 },
|
||||
},
|
||||
refetchInterval: hasActiveTasks ? OVERVIEW_REFRESH_INTERVAL : false,
|
||||
}),
|
||||
)
|
||||
const indexingTask = tasks.find(
|
||||
(task) => ACTIVE_TASK_STATES.has(task.state) && isFirstSourceTask(task),
|
||||
)
|
||||
const indexingSourceQuery = useQuery(
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.get.queryOptions({
|
||||
input: indexingTask?.source_id
|
||||
? {
|
||||
params: {
|
||||
control_space_id: knowledgeSpaceId,
|
||||
source_id: indexingTask.source_id,
|
||||
},
|
||||
}
|
||||
: skipToken,
|
||||
}),
|
||||
)
|
||||
const latestFirstSourceTask = tasks
|
||||
.filter(isFirstSourceTask)
|
||||
.sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at))[0]
|
||||
const failedTask = latestFirstSourceTask?.state === 'failed' ? latestFirstSourceTask : undefined
|
||||
const pageLoading =
|
||||
tasksQuery.isPending ||
|
||||
statsQuery.isPending ||
|
||||
outcomesQuery.isPending ||
|
||||
inventoryQuery.isPending ||
|
||||
attentionQuery.isPending ||
|
||||
(indexingTask?.source_id != null && indexingSourceQuery.isPending)
|
||||
const firstLoadFailed =
|
||||
!pageLoading &&
|
||||
(statsQuery.isError ||
|
||||
outcomesQuery.isError ||
|
||||
inventoryQuery.isError ||
|
||||
attentionQuery.isError)
|
||||
const hasContent =
|
||||
(statsQuery.data?.source_count ?? 0) > 0 || (statsQuery.data?.documents ?? 0) > 0
|
||||
const showIndexing =
|
||||
!pageLoading &&
|
||||
!inventoryQuery.isError &&
|
||||
(inventoryQuery.data?.index_coverage.indexed ?? 0) === 0 &&
|
||||
indexingTask !== undefined
|
||||
const empty = !pageLoading && !statsQuery.isError && !hasContent && !showIndexing
|
||||
const showEmptyModules = empty || showIndexing
|
||||
const retry = () =>
|
||||
void Promise.all([
|
||||
statsQuery.refetch(),
|
||||
outcomesQuery.refetch(),
|
||||
inventoryQuery.refetch(),
|
||||
attentionQuery.refetch(),
|
||||
tasksQuery.refetch(),
|
||||
])
|
||||
function KnowledgeOverviewContent({
|
||||
onWindowChange,
|
||||
window,
|
||||
}: {
|
||||
onWindowChange: (window: OverviewWindow) => void
|
||||
window: OverviewWindow
|
||||
}) {
|
||||
const showEmptyModules = useAtomValue(overviewShowEmptyModulesAtom)
|
||||
const showIndexing = useAtomValue(overviewShowIndexingAtom)
|
||||
|
||||
return (
|
||||
<main className="min-h-0 min-w-0 flex-1 overflow-y-auto bg-components-panel-bg">
|
||||
<div className="w-full px-6 pt-3 pb-6">
|
||||
<header className="relative -top-0.75 flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="title-3xl-bold text-text-primary">
|
||||
{t(($) => $['newKnowledge.overviewTitle'])}
|
||||
</h1>
|
||||
{!empty && !showIndexing && (
|
||||
<SegmentedControl<OverviewWindow>
|
||||
aria-label={t(($) => $['newKnowledge.overview.timeRange'])}
|
||||
value={window}
|
||||
onValueChange={(value) => void setWindow(value)}
|
||||
>
|
||||
{WINDOWS.map((value) => (
|
||||
<SegmentedControlItem<OverviewWindow>
|
||||
key={value}
|
||||
className="h-6.5 px-2.5"
|
||||
value={value}
|
||||
>
|
||||
{value === '24h'
|
||||
? value
|
||||
: value === '7d'
|
||||
? t(($) => $['newKnowledge.overview.sevenDays'])
|
||||
: t(($) => $['newKnowledge.overview.thirtyDays'])}
|
||||
</SegmentedControlItem>
|
||||
))}
|
||||
</SegmentedControl>
|
||||
)}
|
||||
</header>
|
||||
{empty && failedTask && (
|
||||
<FirstSourceTaskFailureBanner
|
||||
failedTask={failedTask}
|
||||
knowledgeSpaceId={knowledgeSpaceId}
|
||||
onRetryTask={tasksQuery.refetch}
|
||||
/>
|
||||
)}
|
||||
<KnowledgeModelReadinessBanner
|
||||
capability="query"
|
||||
className="mt-4"
|
||||
knowledgeSpaceId={knowledgeSpaceId}
|
||||
/>
|
||||
|
||||
{pageLoading && (
|
||||
<p className="sr-only" role="status">
|
||||
{tCommon(($) => $.loading)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{firstLoadFailed && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 flex items-center justify-between gap-4 rounded-xl border border-divider-regular bg-background-section p-4"
|
||||
>
|
||||
<p className="system-sm-regular text-text-destructive">
|
||||
{t(($) => $['newKnowledge.detailErrorDescription'])}
|
||||
</p>
|
||||
<Button size="small" variant="secondary" onClick={retry}>
|
||||
{tCommon(($) => $['operation.retry'])}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showIndexing && (
|
||||
<div className="mt-3">
|
||||
<Onboarding
|
||||
canConnectSource={canConnectSource}
|
||||
canUpload={canUpload}
|
||||
knowledgeSpaceId={knowledgeSpaceId}
|
||||
indexingTask={indexingTask}
|
||||
indexingSourceName={indexingSourceQuery.data?.name}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{empty && (
|
||||
<div className="mt-3">
|
||||
<Onboarding
|
||||
canConnectSource={canConnectSource}
|
||||
canUpload={canUpload}
|
||||
knowledgeSpaceId={knowledgeSpaceId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-3 sm:grid-cols-2 xl:grid-cols-5',
|
||||
showEmptyModules ? 'mt-3 pt-6' : 'mt-3',
|
||||
)}
|
||||
>
|
||||
<MetricCard
|
||||
empty={showEmptyModules}
|
||||
loading={statsQuery.isPending}
|
||||
title={t(($) => $['newKnowledge.overview.queries'])}
|
||||
help={t(($) => $['newKnowledge.overview.queriesHelp'])}
|
||||
value={statsQuery.data ? compactNumber(statsQuery.data.queries.value) : '—'}
|
||||
change={
|
||||
statsQuery.data
|
||||
? changeLabel(
|
||||
statsQuery.data.queries.change_rate === null
|
||||
? null
|
||||
: statsQuery.data.queries.change_rate * 100,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
empty={showEmptyModules}
|
||||
loading={statsQuery.isPending}
|
||||
title={t(($) => $['newKnowledge.overview.answerRate'])}
|
||||
help={t(($) => $['newKnowledge.overview.answerRateHelp'])}
|
||||
value={
|
||||
statsQuery.data ? `${Math.round(statsQuery.data.answer_rate.value * 100)}%` : '—'
|
||||
}
|
||||
change={
|
||||
statsQuery.data
|
||||
? changeLabel(statsQuery.data.answer_rate.change_percentage_points, 'pp')
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
empty={showEmptyModules}
|
||||
loading={statsQuery.isPending}
|
||||
title={t(($) => $['newKnowledge.overview.documents'])}
|
||||
value={statsQuery.data ? compactNumber(statsQuery.data.documents) : '—'}
|
||||
/>
|
||||
<MetricCard
|
||||
empty={showEmptyModules}
|
||||
loading={statsQuery.isPending}
|
||||
title={t(($) => $['newKnowledge.overview.linkedApps'])}
|
||||
value={statsQuery.data ? compactNumber(statsQuery.data.linked_apps) : '—'}
|
||||
/>
|
||||
<MetricCard
|
||||
empty={showEmptyModules}
|
||||
loading={statsQuery.isPending}
|
||||
title={t(($) => $['newKnowledge.overview.freshness'])}
|
||||
help={t(($) => $['newKnowledge.overview.freshnessHelp'])}
|
||||
value={formatDuration(statsQuery.data?.freshness_seconds)}
|
||||
/>
|
||||
<OverviewHeader window={window} onWindowChange={onWindowChange} />
|
||||
<FirstSourceTaskFailureBanner />
|
||||
<OverviewKnowledgeModelReadinessBanner />
|
||||
<OverviewRecoveryStatus />
|
||||
<OverviewOnboarding />
|
||||
<div className={cn('mt-3', showEmptyModules && 'pt-6')}>
|
||||
<OverviewMetrics />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
@ -327,36 +67,101 @@ export function KnowledgeOverviewPage({ knowledgeSpaceId }: { knowledgeSpaceId:
|
||||
showIndexing ? 'mt-3 gap-2.5' : showEmptyModules ? 'mt-2 gap-2.5' : 'mt-3 gap-2.5',
|
||||
)}
|
||||
>
|
||||
<AttentionPanel
|
||||
attention={attentionQuery.data?.data ?? []}
|
||||
empty={showEmptyModules}
|
||||
error={attentionQuery.isError}
|
||||
knowledgeSpaceId={knowledgeSpaceId}
|
||||
loading={attentionQuery.isPending}
|
||||
/>
|
||||
<QueryOutcomesChart
|
||||
buckets={outcomesQuery.data?.buckets ?? []}
|
||||
empty={showEmptyModules}
|
||||
error={outcomesQuery.isError}
|
||||
loading={outcomesQuery.isPending}
|
||||
/>
|
||||
<AttentionPanel />
|
||||
<QueryOutcomesChart />
|
||||
</div>
|
||||
<OverviewActivity
|
||||
empty={showEmptyModules}
|
||||
hasActiveTasks={hasActiveTasks}
|
||||
indexing={showIndexing}
|
||||
knowledgeSpaceId={knowledgeSpaceId}
|
||||
/>
|
||||
<OverviewActivity />
|
||||
<div className="mt-3">
|
||||
<InventoryPanel
|
||||
empty={showEmptyModules}
|
||||
error={inventoryQuery.isError}
|
||||
indexing={showIndexing}
|
||||
inventory={inventoryQuery.data}
|
||||
loading={inventoryQuery.isPending}
|
||||
/>
|
||||
<InventoryPanel />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function OverviewHeader({
|
||||
onWindowChange,
|
||||
window,
|
||||
}: {
|
||||
onWindowChange: (window: OverviewWindow) => void
|
||||
window: OverviewWindow
|
||||
}) {
|
||||
const { t } = useTranslation('dataset')
|
||||
const empty = useAtomValue(overviewEmptyAtom)
|
||||
const showIndexing = useAtomValue(overviewShowIndexingAtom)
|
||||
const setGraphWindow = useSetAtom(overviewWindowAtom)
|
||||
|
||||
return (
|
||||
<header className="relative -top-0.75 flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="title-3xl-bold text-text-primary">
|
||||
{t(($) => $['newKnowledge.overviewTitle'])}
|
||||
</h1>
|
||||
{!empty && !showIndexing && (
|
||||
<SegmentedControl<OverviewWindow>
|
||||
aria-label={t(($) => $['newKnowledge.overview.timeRange'])}
|
||||
value={window}
|
||||
onValueChange={(value) => {
|
||||
setGraphWindow(value)
|
||||
onWindowChange(value)
|
||||
}}
|
||||
>
|
||||
{WINDOWS.map((value) => (
|
||||
<SegmentedControlItem<OverviewWindow>
|
||||
key={value}
|
||||
className="h-6.5 px-2.5"
|
||||
value={value}
|
||||
>
|
||||
{value === '24h'
|
||||
? value
|
||||
: value === '7d'
|
||||
? t(($) => $['newKnowledge.overview.sevenDays'])
|
||||
: t(($) => $['newKnowledge.overview.thirtyDays'])}
|
||||
</SegmentedControlItem>
|
||||
))}
|
||||
</SegmentedControl>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function OverviewKnowledgeModelReadinessBanner() {
|
||||
const knowledgeSpaceId = useAtomValue(overviewKnowledgeSpaceIdAtom)
|
||||
return (
|
||||
<KnowledgeModelReadinessBanner
|
||||
capability="query"
|
||||
className="mt-4"
|
||||
knowledgeSpaceId={knowledgeSpaceId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function OverviewRecoveryStatus() {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const pageLoading = useAtomValue(overviewPageLoadingAtom)
|
||||
const firstLoadFailed = useAtomValue(overviewFirstLoadFailedAtom)
|
||||
const retry = useSetAtom(retryOverviewSnapshotsAtom)
|
||||
|
||||
return (
|
||||
<>
|
||||
{pageLoading && (
|
||||
<p className="sr-only" role="status">
|
||||
{tCommon(($) => $.loading)}
|
||||
</p>
|
||||
)}
|
||||
{firstLoadFailed && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 flex items-center justify-between gap-4 rounded-xl border border-divider-regular bg-background-section p-4"
|
||||
>
|
||||
<p className="system-sm-regular text-text-destructive">
|
||||
{t(($) => $['newKnowledge.detailErrorDescription'])}
|
||||
</p>
|
||||
<Button size="small" variant="secondary" onClick={retry}>
|
||||
{tCommon(($) => $['operation.retry'])}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
48
web/features/new-rag/overview/state-boundary.tsx
Normal file
48
web/features/new-rag/overview/state-boundary.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { OverviewWindow } from './state'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { useEffect } from 'react'
|
||||
import { overviewKnowledgeSpaceIdAtom, overviewWindowAtom } from './state'
|
||||
|
||||
function OverviewWindowBridge({
|
||||
children,
|
||||
window,
|
||||
}: {
|
||||
children: ReactNode
|
||||
window: OverviewWindow
|
||||
}) {
|
||||
const currentWindow = useAtomValue(overviewWindowAtom)
|
||||
const setWindow = useSetAtom(overviewWindowAtom)
|
||||
|
||||
useEffect(() => {
|
||||
if (currentWindow !== window) setWindow(window)
|
||||
}, [currentWindow, setWindow, window])
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export function OverviewStateBoundary({
|
||||
children,
|
||||
knowledgeSpaceId,
|
||||
window,
|
||||
}: {
|
||||
children: ReactNode
|
||||
knowledgeSpaceId: string
|
||||
window: OverviewWindow
|
||||
}) {
|
||||
return (
|
||||
<ScopeProvider
|
||||
key={knowledgeSpaceId}
|
||||
atoms={[
|
||||
[overviewKnowledgeSpaceIdAtom, knowledgeSpaceId],
|
||||
[overviewWindowAtom, window],
|
||||
]}
|
||||
name="KnowledgeOverview"
|
||||
>
|
||||
<OverviewWindowBridge window={window}>{children}</OverviewWindowBridge>
|
||||
</ScopeProvider>
|
||||
)
|
||||
}
|
||||
254
web/features/new-rag/overview/state.ts
Normal file
254
web/features/new-rag/overview/state.ts
Normal file
@ -0,0 +1,254 @@
|
||||
import type { KnowledgeFsBackgroundTaskResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
|
||||
import { skipToken } from '@tanstack/react-query'
|
||||
import { atom } from 'jotai'
|
||||
import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query'
|
||||
import { atomWithLazy, selectAtom } from 'jotai/utils'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { OVERVIEW_REFRESH_INTERVAL, overviewRefreshInterval } from './overview-format'
|
||||
|
||||
export type OverviewWindow = '24h' | '7d' | '30d'
|
||||
|
||||
const ACTIVE_TASK_STATES = new Set<KnowledgeFsBackgroundTaskResponse['state']>([
|
||||
'queued',
|
||||
'running',
|
||||
])
|
||||
const TASK_PAGE_SIZE = 20
|
||||
|
||||
export const overviewKnowledgeSpaceIdAtom = atomWithLazy<string>(() => {
|
||||
throw new Error('Missing overview knowledge space id')
|
||||
})
|
||||
export const overviewWindowAtom = atom<OverviewWindow>('24h')
|
||||
|
||||
function isFirstSourceTask(task: KnowledgeFsBackgroundTaskResponse) {
|
||||
return (
|
||||
task.operation === 'document_processing' ||
|
||||
task.operation === 'document_upload' ||
|
||||
task.operation.includes('import')
|
||||
)
|
||||
}
|
||||
|
||||
const backgroundTasksQueryAtom = atomWithInfiniteQuery((get) =>
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.backgroundTasks.get.infiniteOptions({
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor,
|
||||
initialPageParam: null as string | null,
|
||||
input: (pageParam) => ({
|
||||
params: { control_space_id: get(overviewKnowledgeSpaceIdAtom) },
|
||||
query: {
|
||||
...(typeof pageParam === 'string' ? { cursor: pageParam } : {}),
|
||||
limit: TASK_PAGE_SIZE,
|
||||
},
|
||||
}),
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.pages.some((page) =>
|
||||
page.data.some((task) => ACTIVE_TASK_STATES.has(task.state)),
|
||||
)
|
||||
? OVERVIEW_REFRESH_INTERVAL
|
||||
: false,
|
||||
}),
|
||||
)
|
||||
|
||||
const backgroundTasksQueryDataAtom = selectAtom(backgroundTasksQueryAtom, (query) => query.data)
|
||||
const backgroundTasksRefetchAtom = selectAtom(backgroundTasksQueryAtom, (query) => query.refetch)
|
||||
const backgroundTasksPendingAtom = selectAtom(backgroundTasksQueryAtom, (query) => query.isPending)
|
||||
|
||||
const backgroundTasksAtom = atom(
|
||||
(get) => get(backgroundTasksQueryDataAtom)?.pages.flatMap((page) => page.data) ?? [],
|
||||
)
|
||||
const overviewHasActiveTasksAtom = atom((get) =>
|
||||
get(backgroundTasksAtom).some((task) => ACTIVE_TASK_STATES.has(task.state)),
|
||||
)
|
||||
const latestTaskUpdatedAtAtom = atom((get) =>
|
||||
get(backgroundTasksAtom).reduce<number | undefined>((latest, task) => {
|
||||
const updatedAt = Date.parse(task.updated_at)
|
||||
if (!Number.isFinite(updatedAt)) return latest
|
||||
return latest === undefined || updatedAt > latest ? updatedAt : latest
|
||||
}, undefined),
|
||||
)
|
||||
export const overviewIndexingTaskAtom = atom((get) =>
|
||||
get(backgroundTasksAtom).find(
|
||||
(task) => ACTIVE_TASK_STATES.has(task.state) && isFirstSourceTask(task),
|
||||
),
|
||||
)
|
||||
export const overviewFailedFirstSourceTaskAtom = atom((get) => {
|
||||
const latestTask = get(backgroundTasksAtom)
|
||||
.filter(isFirstSourceTask)
|
||||
.sort((left, right) => Date.parse(right.updated_at) - Date.parse(left.updated_at))[0]
|
||||
return latestTask?.state === 'failed' ? latestTask : undefined
|
||||
})
|
||||
|
||||
const statsQueryAtom = atomWithQuery((get) =>
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.stats.get.queryOptions({
|
||||
input: {
|
||||
params: { control_space_id: get(overviewKnowledgeSpaceIdAtom) },
|
||||
query: { window: get(overviewWindowAtom) },
|
||||
},
|
||||
refetchInterval: (query) =>
|
||||
overviewRefreshInterval({
|
||||
generatedAt: query.state.data?.generated_at,
|
||||
hasActiveTasks: get(overviewHasActiveTasksAtom),
|
||||
latestTaskUpdatedAt: get(latestTaskUpdatedAtAtom),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
export const overviewStatsDataAtom = selectAtom(statsQueryAtom, (query) => query.data)
|
||||
export const overviewStatsPendingAtom = selectAtom(statsQueryAtom, (query) => query.isPending)
|
||||
const statsErrorAtom = selectAtom(statsQueryAtom, (query) => query.isError)
|
||||
const statsRefetchAtom = selectAtom(statsQueryAtom, (query) => query.refetch)
|
||||
|
||||
const outcomesQueryAtom = atomWithQuery((get) =>
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.queryOutcomes.get.queryOptions({
|
||||
input: {
|
||||
params: { control_space_id: get(overviewKnowledgeSpaceIdAtom) },
|
||||
query: { window: get(overviewWindowAtom) },
|
||||
},
|
||||
refetchInterval: (query) =>
|
||||
overviewRefreshInterval({
|
||||
generatedAt: query.state.data?.generated_at,
|
||||
hasActiveTasks: get(overviewHasActiveTasksAtom),
|
||||
latestTaskUpdatedAt: get(latestTaskUpdatedAtAtom),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
export const overviewOutcomesDataAtom = selectAtom(outcomesQueryAtom, (query) => query.data)
|
||||
export const overviewOutcomesPendingAtom = selectAtom(outcomesQueryAtom, (query) => query.isPending)
|
||||
export const overviewOutcomesErrorAtom = selectAtom(outcomesQueryAtom, (query) => query.isError)
|
||||
const outcomesRefetchAtom = selectAtom(outcomesQueryAtom, (query) => query.refetch)
|
||||
|
||||
const inventoryQueryAtom = atomWithQuery((get) =>
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.inventory.get.queryOptions({
|
||||
input: { params: { control_space_id: get(overviewKnowledgeSpaceIdAtom) } },
|
||||
refetchInterval: (query) =>
|
||||
overviewRefreshInterval({
|
||||
generatedAt: query.state.data?.generated_at,
|
||||
hasActiveTasks: get(overviewHasActiveTasksAtom),
|
||||
latestTaskUpdatedAt: get(latestTaskUpdatedAtAtom),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
export const overviewInventoryDataAtom = selectAtom(inventoryQueryAtom, (query) => query.data)
|
||||
export const overviewInventoryPendingAtom = selectAtom(
|
||||
inventoryQueryAtom,
|
||||
(query) => query.isPending,
|
||||
)
|
||||
export const overviewInventoryErrorAtom = selectAtom(inventoryQueryAtom, (query) => query.isError)
|
||||
const inventoryRefetchAtom = selectAtom(inventoryQueryAtom, (query) => query.refetch)
|
||||
|
||||
const attentionQueryAtom = atomWithQuery((get) =>
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.attention.get.queryOptions({
|
||||
input: {
|
||||
params: { control_space_id: get(overviewKnowledgeSpaceIdAtom) },
|
||||
query: { include_dismissed: false, limit: 100 },
|
||||
},
|
||||
refetchInterval: get(overviewHasActiveTasksAtom) ? OVERVIEW_REFRESH_INTERVAL : false,
|
||||
}),
|
||||
)
|
||||
export const overviewAttentionDataAtom = selectAtom(
|
||||
attentionQueryAtom,
|
||||
(query) => query.data?.data ?? [],
|
||||
)
|
||||
export const overviewAttentionPendingAtom = selectAtom(
|
||||
attentionQueryAtom,
|
||||
(query) => query.isPending,
|
||||
)
|
||||
export const overviewAttentionErrorAtom = selectAtom(attentionQueryAtom, (query) => query.isError)
|
||||
const attentionRefetchAtom = selectAtom(attentionQueryAtom, (query) => query.refetch)
|
||||
|
||||
const indexingSourceQueryAtom = atomWithQuery((get) => {
|
||||
const indexingTask = get(overviewIndexingTaskAtom)
|
||||
return consoleQuery.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.get.queryOptions({
|
||||
input: indexingTask?.source_id
|
||||
? {
|
||||
params: {
|
||||
control_space_id: get(overviewKnowledgeSpaceIdAtom),
|
||||
source_id: indexingTask.source_id,
|
||||
},
|
||||
}
|
||||
: skipToken,
|
||||
})
|
||||
})
|
||||
export const overviewIndexingSourceNameAtom = atom((get) => get(indexingSourceQueryAtom).data?.name)
|
||||
const indexingSourcePendingAtom = selectAtom(indexingSourceQueryAtom, (query) => query.isPending)
|
||||
|
||||
const activityPreviewQueryAtom = atomWithQuery((get) =>
|
||||
consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.activity.get.queryOptions({
|
||||
input: {
|
||||
params: { control_space_id: get(overviewKnowledgeSpaceIdAtom) },
|
||||
query: { limit: 5 },
|
||||
},
|
||||
refetchInterval: get(overviewHasActiveTasksAtom) ? OVERVIEW_REFRESH_INTERVAL : false,
|
||||
}),
|
||||
)
|
||||
export const overviewActivityPreviewDataAtom = selectAtom(
|
||||
activityPreviewQueryAtom,
|
||||
(query) => query.data?.data ?? [],
|
||||
)
|
||||
export const overviewActivityPreviewPendingAtom = selectAtom(
|
||||
activityPreviewQueryAtom,
|
||||
(query) => query.isPending,
|
||||
)
|
||||
export const overviewActivityPreviewErrorAtom = selectAtom(
|
||||
activityPreviewQueryAtom,
|
||||
(query) => query.isError,
|
||||
)
|
||||
export const overviewActivityPreviewRefetchingAtom = selectAtom(
|
||||
activityPreviewQueryAtom,
|
||||
(query) => query.isRefetching,
|
||||
)
|
||||
const activityPreviewRefetchAtom = selectAtom(activityPreviewQueryAtom, (query) => query.refetch)
|
||||
|
||||
export const overviewPageLoadingAtom = atom((get) => {
|
||||
const indexingTask = get(overviewIndexingTaskAtom)
|
||||
return (
|
||||
get(backgroundTasksPendingAtom) ||
|
||||
get(overviewStatsPendingAtom) ||
|
||||
get(overviewOutcomesPendingAtom) ||
|
||||
get(overviewInventoryPendingAtom) ||
|
||||
get(overviewAttentionPendingAtom) ||
|
||||
(indexingTask?.source_id != null && get(indexingSourcePendingAtom))
|
||||
)
|
||||
})
|
||||
export const overviewFirstLoadFailedAtom = atom(
|
||||
(get) =>
|
||||
!get(overviewPageLoadingAtom) &&
|
||||
(get(statsErrorAtom) ||
|
||||
get(overviewOutcomesErrorAtom) ||
|
||||
get(overviewInventoryErrorAtom) ||
|
||||
get(overviewAttentionErrorAtom)),
|
||||
)
|
||||
const overviewHasContentAtom = atom((get) => {
|
||||
const stats = get(overviewStatsDataAtom)
|
||||
return (stats?.source_count ?? 0) > 0 || (stats?.documents ?? 0) > 0
|
||||
})
|
||||
export const overviewShowIndexingAtom = atom(
|
||||
(get) =>
|
||||
!get(overviewPageLoadingAtom) &&
|
||||
!get(overviewInventoryErrorAtom) &&
|
||||
(get(overviewInventoryDataAtom)?.index_coverage.indexed ?? 0) === 0 &&
|
||||
get(overviewIndexingTaskAtom) !== undefined,
|
||||
)
|
||||
export const overviewEmptyAtom = atom(
|
||||
(get) =>
|
||||
!get(overviewPageLoadingAtom) &&
|
||||
!get(statsErrorAtom) &&
|
||||
!get(overviewHasContentAtom) &&
|
||||
!get(overviewShowIndexingAtom),
|
||||
)
|
||||
export const overviewShowEmptyModulesAtom = atom(
|
||||
(get) => get(overviewEmptyAtom) || get(overviewShowIndexingAtom),
|
||||
)
|
||||
|
||||
export const retryOverviewSnapshotsAtom = atom(null, (get) => {
|
||||
void Promise.all([
|
||||
get(statsRefetchAtom)(),
|
||||
get(outcomesRefetchAtom)(),
|
||||
get(inventoryRefetchAtom)(),
|
||||
get(attentionRefetchAtom)(),
|
||||
get(backgroundTasksRefetchAtom)(),
|
||||
])
|
||||
})
|
||||
export const refreshOverviewBackgroundTasksAtom = atom(null, (get) =>
|
||||
get(backgroundTasksRefetchAtom)(),
|
||||
)
|
||||
export const retryOverviewActivityPreviewAtom = atom(null, (get) => {
|
||||
void get(activityPreviewRefetchAtom)()
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user