refactor(knowledge-fs): decompose overview page

This commit is contained in:
Stephen Zhou 2026-09-01 23:21:34 +08:00
parent 8780803f2d
commit d3f59d8458
No known key found for this signature in database
10 changed files with 817 additions and 561 deletions

View File

@ -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()
})

View File

@ -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()
})
})

View File

@ -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')

View File

@ -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.

View File

@ -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
? [
{

View File

@ -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}

View File

@ -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.
}

View File

@ -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>
)}
</>
)
}

View 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>
)
}

View 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)()
})