diff --git a/api/controllers/console/knowledge_fs/resources.py b/api/controllers/console/knowledge_fs/resources.py index ff83272d9fd..6bc25725a3d 100644 --- a/api/controllers/console/knowledge_fs/resources.py +++ b/api/controllers/console/knowledge_fs/resources.py @@ -1040,6 +1040,8 @@ class KnowledgeFSSpaceOverviewActivityApi(Resource): account_id=actor_id, control_space_id=control_space_id, action=query.action, + actor_id=query.actor_id, + actor_type=query.actor_type, cursor=query.cursor, from_at=query.from_at.astimezone(UTC).isoformat().replace("+00:00", "Z") if query.from_at else None, limit=query.limit, diff --git a/api/services/knowledge_fs/data_facade.py b/api/services/knowledge_fs/data_facade.py index 9b936b00c8a..5fed1acfea1 100644 --- a/api/services/knowledge_fs/data_facade.py +++ b/api/services/knowledge_fs/data_facade.py @@ -257,6 +257,8 @@ class KnowledgeFSDataFacade: account_id: str, control_space_id: str, action: str | None, + actor_id: str | None, + actor_type: str | None, cursor: str | None, from_at: str | None, limit: int, @@ -268,6 +270,8 @@ class KnowledgeFSDataFacade: (key, value) for key, value in ( ("action", action), + ("actorId", actor_id), + ("actorType", actor_type), ("cursor", cursor), ("from", from_at), ("limit", str(limit)), diff --git a/api/services/knowledge_fs/product_dto.py b/api/services/knowledge_fs/product_dto.py index ee452b8874f..ce5441829a3 100644 --- a/api/services/knowledge_fs/product_dto.py +++ b/api/services/knowledge_fs/product_dto.py @@ -418,6 +418,8 @@ class KnowledgeFSOverviewActivityListQuery(BaseModel): ] | None ) = None + actor_id: str | None = Field(default=None, min_length=1, max_length=255) + actor_type: Literal["member", "system"] | None = None cursor: str | None = Field(default=None, min_length=1, max_length=512) from_at: datetime | None = Field(default=None, validation_alias=AliasChoices("from_at", "from")) limit: int = Field(default=50, ge=1, le=100) @@ -441,6 +443,8 @@ class KnowledgeFSOverviewActivityListQuery(BaseModel): @model_validator(mode="after") def validate_time_range(self) -> KnowledgeFSOverviewActivityListQuery: + if self.actor_type == "system" and self.actor_id is not None: + raise ValueError("actor_id cannot be combined with the system actor type") if self.from_at is not None and self.to_at is not None and self.from_at > self.to_at: raise ValueError("from must not be after to") return self diff --git a/api/tests/unit_tests/controllers/test_knowledge_fs_resource_delegation.py b/api/tests/unit_tests/controllers/test_knowledge_fs_resource_delegation.py index 970835b1448..4cf1bf2a69e 100644 --- a/api/tests/unit_tests/controllers/test_knowledge_fs_resource_delegation.py +++ b/api/tests/unit_tests/controllers/test_knowledge_fs_resource_delegation.py @@ -198,6 +198,8 @@ _CONSOLE_DELEGATION_CASES = ( { "control_space_id": "space-1", "action": None, + "actor_id": None, + "actor_type": None, "cursor": None, "from_at": None, "limit": 50, diff --git a/api/tests/unit_tests/services/test_knowledge_fs_data_facade.py b/api/tests/unit_tests/services/test_knowledge_fs_data_facade.py index 0f3e54f3d14..b3ed836d886 100644 --- a/api/tests/unit_tests/services/test_knowledge_fs_data_facade.py +++ b/api/tests/unit_tests/services/test_knowledge_fs_data_facade.py @@ -1117,6 +1117,8 @@ def test_advanced_facade_binds_child_resources_parent_space_and_idempotency() -> "listOverviewActivity", { "action": None, + "actor_id": None, + "actor_type": None, "cursor": "cursor-1", "from_at": "2026-07-01T00:00:00Z", "limit": 20, diff --git a/knowledge-fs/packages/api/src/knowledge-space-overview-database-repository.test.ts b/knowledge-fs/packages/api/src/knowledge-space-overview-database-repository.test.ts index e5bd5516642..dc7ed1bb486 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-overview-database-repository.test.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-overview-database-repository.test.ts @@ -639,6 +639,8 @@ describe.each(["postgres", "tidb"] as const)( const page = await repository.listActivity({ action: "query.requested", + actorId: "editor-1", + actorType: "member", candidateGrants: ["team:camera"], cursor: { id: QUERY_ID, occurredAt: NOW }, from: "2026-07-13T14:00:00.000Z", @@ -657,6 +659,8 @@ describe.each(["postgres", "tidb"] as const)( SPACE_ID, JSON.stringify(["team:camera"]), "query.requested", + "member", + "editor-1", "query", "pending", "2026-07-13T14:00:00.000Z", diff --git a/knowledge-fs/packages/api/src/knowledge-space-overview-database-repository.ts b/knowledge-fs/packages/api/src/knowledge-space-overview-database-repository.ts index 48c46ba8983..5aa1c640cc0 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-overview-database-repository.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-overview-database-repository.ts @@ -93,6 +93,14 @@ export function createDatabaseKnowledgeSpaceOverviewRepository({ params.push(input.action); filters.push(`event.${q(database, "action")} = ${p(database, params.length)}`); } + if (input.actorType) { + params.push(input.actorType); + filters.push(`event.${q(database, "actor_type")} = ${p(database, params.length)}`); + } + if (input.actorId) { + params.push(input.actorId); + filters.push(`event.${q(database, "actor_subject_id")} = ${p(database, params.length)}`); + } if (input.resourceType) { params.push(input.resourceType); filters.push(`event.${q(database, "resource_type")} = ${p(database, params.length)}`); diff --git a/knowledge-fs/packages/api/src/knowledge-space-overview-handlers-branch-coverage.test.ts b/knowledge-fs/packages/api/src/knowledge-space-overview-handlers-branch-coverage.test.ts index 2977f21b441..80293ba14ab 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-overview-handlers-branch-coverage.test.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-overview-handlers-branch-coverage.test.ts @@ -97,6 +97,8 @@ describe("knowledge-space Overview handler branch coverage", () => { activityResult: { items: [activity()], nextCursor }, query: { action: "document.failed", + actorId: "dify-account:editor-1", + actorType: "member", cursor: encodeKnowledgeSpaceActivityCursor(nextCursor), from: "2026-07-13T12:00:00.000Z", limit: 5, @@ -114,6 +116,8 @@ describe("knowledge-space Overview handler branch coverage", () => { expect(fixture.overview.listActivity).toHaveBeenCalledWith( expect.objectContaining({ action: "document.failed", + actorId: "dify-account:editor-1", + actorType: "member", resourceType: "document", result: "failure", }), diff --git a/knowledge-fs/packages/api/src/knowledge-space-overview-handlers.test.ts b/knowledge-fs/packages/api/src/knowledge-space-overview-handlers.test.ts index e5d6ec3e129..5cdcacbeeaa 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-overview-handlers.test.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-overview-handlers.test.ts @@ -25,7 +25,7 @@ describe("knowledge-space Overview HTTP API", () => { const fixture = await createFixture(); const response = await fixture.app.request( - `/knowledge-spaces/${SPACE_ID}/overview/activity?limit=5`, + `/knowledge-spaces/${SPACE_ID}/overview/activity?limit=5&actorType=member&actorId=dify-account%3Aviewer-1`, { headers: { authorization: "Bearer viewer-token" } }, ); @@ -40,6 +40,8 @@ describe("knowledge-space Overview HTTP API", () => { expect(fixture.listActivity).toHaveBeenCalledWith( expect.objectContaining({ candidateGrants: expect.any(Array), + actorId: "dify-account:viewer-1", + actorType: "member", knowledgeSpaceId: SPACE_ID, tenantId: TENANT_ID, }), diff --git a/knowledge-fs/packages/api/src/knowledge-space-overview-handlers.ts b/knowledge-fs/packages/api/src/knowledge-space-overview-handlers.ts index 18a4e30a26a..87bad58d616 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-overview-handlers.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-overview-handlers.ts @@ -107,6 +107,8 @@ export function registerKnowledgeSpaceOverviewHandlers(input: { try { const result = await input.overview.listActivity({ ...(query.action ? { action: query.action } : {}), + ...(query.actorId ? { actorId: query.actorId } : {}), + ...(query.actorType ? { actorType: query.actorType } : {}), candidateGrants: scope.candidateGrants, ...(query.cursor ? { cursor: decodeKnowledgeSpaceActivityCursor(query.cursor) } : {}), ...(query.from ? { from: query.from } : {}), diff --git a/knowledge-fs/packages/api/src/knowledge-space-overview-schemas.ts b/knowledge-fs/packages/api/src/knowledge-space-overview-schemas.ts index b6515086ef9..868c1aa7edf 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-overview-schemas.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-overview-schemas.ts @@ -27,6 +27,8 @@ export const KnowledgeSpaceAttentionParamsSchema = z.object({ export const ListKnowledgeSpaceActivityQuerySchema = z .object({ action: z.enum(KnowledgeSpaceActivityActions).optional(), + actorId: z.string().min(1).max(255).optional(), + actorType: z.enum(["member", "system"]).optional(), cursor: z.string().min(1).max(512).optional(), from: IsoDateTimeSchema.optional(), limit: BoundedOverviewLimitSchema, @@ -35,6 +37,9 @@ export const ListKnowledgeSpaceActivityQuerySchema = z to: IsoDateTimeSchema.optional(), }) .strict() + .refine((value) => value.actorType !== "system" || !value.actorId, { + message: "actorId cannot be combined with the system actor type", + }) .refine((value) => !value.from || !value.to || value.from <= value.to, { message: "from must not be after to", }); diff --git a/knowledge-fs/packages/api/src/knowledge-space-overview.ts b/knowledge-fs/packages/api/src/knowledge-space-overview.ts index 4057eaa0523..3ca4c4d4c33 100644 --- a/knowledge-fs/packages/api/src/knowledge-space-overview.ts +++ b/knowledge-fs/packages/api/src/knowledge-space-overview.ts @@ -68,6 +68,8 @@ export interface KnowledgeSpaceActivityCursor { export interface ListKnowledgeSpaceActivityInput { readonly action?: KnowledgeSpaceActivityAction | undefined; + readonly actorId?: string | undefined; + readonly actorType?: "member" | "system" | undefined; readonly candidateGrants: readonly string[]; readonly cursor?: KnowledgeSpaceActivityCursor | undefined; readonly from?: string | undefined; @@ -398,6 +400,8 @@ export function createInMemoryKnowledgeSpaceOverviewRepository(options: { candidatePermissionScopeAllows(event.requiredPermissionScope, input.candidateGrants), ) .filter((event) => !input.action || event.action === input.action) + .filter((event) => !input.actorType || event.actor.type === input.actorType) + .filter((event) => !input.actorId || event.actor.id === input.actorId) .filter((event) => !input.resourceType || event.resource.type === input.resourceType) .filter((event) => !input.result || event.result === input.result) .filter((event) => !input.from || event.occurredAt >= input.from) diff --git a/packages/contracts/generated/api/console/knowledge-fs/types.gen.ts b/packages/contracts/generated/api/console/knowledge-fs/types.gen.ts index 4b81eb6f656..9d3574401cb 100644 --- a/packages/contracts/generated/api/console/knowledge-fs/types.gen.ts +++ b/packages/contracts/generated/api/console/knowledge-fs/types.gen.ts @@ -2237,6 +2237,8 @@ export type GetKnowledgeFsSpacesByControlSpaceIdOverviewActivityData = { | 'source.failed' | 'source.synced' | 'worker.failed' + actor_id?: string + actor_type?: 'member' | 'system' cursor?: string from_at?: string limit?: number diff --git a/packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts b/packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts index c844fab9f2c..dbccb148d46 100644 --- a/packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts +++ b/packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts @@ -2536,6 +2536,8 @@ export const zGetKnowledgeFsSpacesByControlSpaceIdOverviewActivityQuery = z.obje 'worker.failed', ]) .optional(), + actor_id: z.string().min(1).max(255).optional(), + actor_type: z.enum(['member', 'system']).optional(), cursor: z.string().min(1).max(512).optional(), from_at: z.iso.datetime().optional(), limit: z.int().gte(1).lte(100).optional().default(50), diff --git a/web/features/new-rag/__tests__/knowledge-overview-page.spec.tsx b/web/features/new-rag/__tests__/knowledge-overview-page.spec.tsx index 292dc518187..7b53dcaa5d5 100644 --- a/web/features/new-rag/__tests__/knowledge-overview-page.spec.tsx +++ b/web/features/new-rag/__tests__/knowledge-overview-page.spec.tsx @@ -1,5 +1,6 @@ import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import dayjs from 'dayjs' import { renderWithNuqs } from '@/test/nuqs-testing' import { KnowledgeOverviewPage } from '../overview/knowledge-overview-page' @@ -11,6 +12,11 @@ const queryOptionsMocks = vi.hoisted(() => ({ stats: vi.fn(), })) +const infiniteOptionsMocks = vi.hoisted(() => ({ + activity: vi.fn(), + tasks: vi.fn(), +})) + const chartOptions = vi.hoisted(() => ({ current: undefined as unknown })) const queryData = vi.hoisted(() => ({ @@ -19,7 +25,7 @@ const queryData = vi.hoisted(() => ({ { action: 'source.synced', actor: { id: 'dify-account:member-1', type: 'member' }, - details: { count: 1 }, + details: { count: 1 } as Record, id: 'activity-1', occurred_at: '2026-07-29T08:05:00Z', resource: { id: 'source-1', type: 'source' }, @@ -258,14 +264,20 @@ vi.mock('@/service/client', () => { }, }, get: { - infiniteOptions: () => ({ queryKey: ['tasksInfinite'] }), + infiniteOptions: (options: unknown) => { + infiniteOptionsMocks.tasks(options) + return { ...(options as object), queryKey: ['tasksInfinite'] } + }, }, }, overview: { activity: { get: { ...query('activity'), - infiniteOptions: () => ({ queryKey: ['activityInfinite'] }), + infiniteOptions: (options: unknown) => { + infiniteOptionsMocks.activity(options) + return { ...(options as object), queryKey: ['activityInfinite'] } + }, }, }, attention: { get: query('attention') }, @@ -516,6 +528,9 @@ describe('KnowledgeOverviewPage', () => { it('opens the complete activity view from recent activity', async () => { const user = userEvent.setup() renderWithNuqs() + const whenHeader = screen.getByRole('columnheader', { + name: 'dataset.newKnowledge.overview.when', + }) await user.click( screen.getByRole('button', { name: 'dataset.newKnowledge.overview.allActivity' }), @@ -530,8 +545,92 @@ describe('KnowledgeOverviewPage', () => { screen.getByRole('combobox', { name: 'dataset.newKnowledge.overview.timeRange' }), ).toBeInTheDocument() expect( - screen.queryByRole('combobox', { name: 'dataset.newKnowledge.overview.operator' }), - ).not.toBeInTheDocument() + screen.getByRole('combobox', { name: 'dataset.newKnowledge.overview.operator' }), + ).toBeInTheDocument() + expect( + screen.getByRole('group', { name: 'dataset.newKnowledge.overview.timeRange' }), + ).toBeInTheDocument() + expect(document.querySelector('.bg-transparent')).toBeInTheDocument() + expect(whenHeader).not.toHaveClass('opacity-0') + }) + + it('restarts activity pagination when the date or operator filter changes', async () => { + const user = userEvent.setup() + renderWithNuqs() + await user.click( + screen.getByRole('button', { name: 'dataset.newKnowledge.overview.allActivity' }), + ) + + const operator = screen.getByRole('combobox', { + name: 'dataset.newKnowledge.overview.operator', + }) + await user.click(operator) + await user.click(await screen.findByRole('option', { name: 'Ada' })) + + const memberOptions = infiniteOptionsMocks.activity.mock.lastCall?.[0] as { + input: (cursor: string | null) => { + query: Record + } + queryKey: unknown[] + } + expect(memberOptions.queryKey).toEqual([ + 'knowledge-fs-overview-activity', + 'space-1', + expect.any(String), + expect.any(String), + 'member:member-1', + ]) + expect(memberOptions.input(null).query).toMatchObject({ + actor_id: 'dify-account:member-1', + actor_type: 'member', + limit: 20, + }) + expect(memberOptions.input('older').query.cursor).toBe('older') + + const timeRange = screen.getByRole('combobox', { + name: 'dataset.newKnowledge.overview.timeRange', + }) + await user.click(timeRange) + await user.click( + await screen.findByRole('option', { name: 'dataset.newKnowledge.overview.today' }), + ) + + const todayOptions = infiniteOptionsMocks.activity.mock.lastCall?.[0] as { + input: (cursor: string | null) => { + query: Record + } + queryKey: unknown[] + } + expect(todayOptions.queryKey[4]).toBe('member:member-1') + expect(todayOptions.input(null).query).toMatchObject({ + actor_id: 'dify-account:member-1', + actor_type: 'member', + from_at: expect.any(String), + to_at: expect.any(String), + }) + expect(dayjs(todayOptions.input(null).query.from_at as string).isSame(dayjs(), 'day')).toBe( + true, + ) + }) + + it('shows safe activity details and relative times for today in the drawer', async () => { + const user = userEvent.setup() + queryData.activity.data[0]!.action = 'source.failed' + queryData.activity.data[0]!.details = { reasonCode: 'CREDENTIALS_EXPIRED' } + queryData.activity.data[0]!.occurred_at = new Date(Date.now() - 2 * 60 * 60_000).toISOString() + queryData.activity.data[0]!.result = 'failure' + + renderWithNuqs() + await user.click( + screen.getByRole('button', { name: 'dataset.newKnowledge.overview.allActivity' }), + ) + + const dialog = screen.getByRole('dialog', { + name: 'dataset.newKnowledge.overview.allActivity', + }) + expect(dialog).toHaveTextContent('Credentials expired') + expect(dialog).toHaveTextContent('dataset.newKnowledge.overview.activityFailed') + expect(within(dialog).getByText(/2h ago|2 hr\. ago|2 hours ago/)).toBeInTheDocument() }) it('refreshes overview snapshots until they catch up with a completed background task', () => { diff --git a/web/features/new-rag/overview/knowledge-overview-page.tsx b/web/features/new-rag/overview/knowledge-overview-page.tsx index f6c8d7acde2..4e216389083 100644 --- a/web/features/new-rag/overview/knowledge-overview-page.tsx +++ b/web/features/new-rag/overview/knowledge-overview-page.tsx @@ -6,8 +6,10 @@ import type { KnowledgeFsOverviewAttentionResponse, KnowledgeFsOverviewQueryOutcomeBucketResponse, } from '@dify/contracts/api/console/knowledge-fs/types.gen' +import type { Dayjs } from 'dayjs' import type { EChartsOption } from 'echarts' import type { CSSProperties } from 'react' +import type { TriggerProps } from '@/app/components/base/date-and-time-picker/types' import type { Member } from '@/models/common' import { Avatar } from '@langgenius/dify-ui/avatar' import { Button } from '@langgenius/dify-ui/button' @@ -38,6 +40,7 @@ import { useAtomValue } from 'jotai' import { parseAsStringLiteral, useQueryState } from 'nuqs' 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 { Infotip } from '@/app/components/base/infotip' import { datasetDefaultPermissionKeysAtom, @@ -57,9 +60,12 @@ import { } from '../routes' type OverviewWindow = '24h' | '7d' | '30d' -type ActivityRange = 'today' | '7d' | '30d' | '90d' | 'all' +type ActivityRange = 'today' | '7d' | '30d' | '90d' | 'all' | 'custom' +type ActivityOperator = 'all' | 'system' | `member:${string}` +type ActivityDateRange = { end: Dayjs; start: Dayjs } const WINDOWS: OverviewWindow[] = ['24h', '7d', '30d'] +const ACTIVITY_RANGES: ActivityRange[] = ['today', '7d', '30d', '90d', 'all', 'custom'] const QUERY_OUTCOMES_WINDOW: OverviewWindow = '7d' const ACTIVE_TASK_STATES = new Set([ 'queued', @@ -72,6 +78,18 @@ const overviewWindowParser = parseAsStringLiteral(WINDOWS) .withDefault('24h') .withOptions({ history: 'push' }) +function activityDatesForRange(range: Exclude): ActivityDateRange { + const end = dayjs().endOf('day') + if (range === 'all') return { end, start: dayjs(0) } + if (range === 'today') return { end, start: dayjs().startOf('day') } + return { + end, + start: dayjs() + .subtract(Number.parseInt(range) - 1, 'day') + .startOf('day'), + } +} + function isFirstSourceTask(task: KnowledgeFsBackgroundTaskResponse) { return ( task.operation === 'document_processing' || @@ -629,15 +647,35 @@ function activityLabel( t: ReturnType>['t'], ) { const operation = activityOperationLabel(activity, t) + let label: string if (activity.result === 'success') - return t(($) => $['newKnowledge.overview.activityCompleted'], { operation }) - if (activity.result === 'failure') - return t(($) => $['newKnowledge.overview.activityFailed'], { operation }) - if (activity.result === 'canceled') - return t(($) => $['newKnowledge.overview.activityCanceled'], { operation }) - return activity.action === 'query.requested' - ? t(($) => $['newKnowledge.overview.activityQueued'], { operation }) - : t(($) => $['newKnowledge.overview.activityRunning'], { operation }) + label = t(($) => $['newKnowledge.overview.activityCompleted'], { operation }) + else if (activity.result === 'failure') + label = t(($) => $['newKnowledge.overview.activityFailed'], { operation }) + else if (activity.result === 'canceled') + label = t(($) => $['newKnowledge.overview.activityCanceled'], { operation }) + else + label = + activity.action === 'query.requested' + ? t(($) => $['newKnowledge.overview.activityQueued'], { operation }) + : t(($) => $['newKnowledge.overview.activityRunning'], { operation }) + + const detail = [ + activity.details.reasonCode, + activity.details.statusCode, + activity.details.documentType, + activity.details.providerId, + activity.details.mode, + ].find((value): value is string => typeof value === 'string' && Boolean(value.trim())) + if (!detail) return label + + const readableDetail = /^[A-Z0-9_]+$/.test(detail) + ? detail + .toLocaleLowerCase() + .replaceAll('_', ' ') + .replace(/^./, (character) => character.toLocaleUpperCase()) + : detail + return `${label} — ${readableDetail}` } function compactIdentifier(value: string) { @@ -809,9 +847,7 @@ function RecentActivity({ role="row" className="grid grid-cols-[100px_minmax(280px,1fr)_200px] items-center gap-3 pb-2 system-2xs-medium-uppercase text-text-tertiary" > - - {t(($) => $['newKnowledge.overview.when'])} - + {t(($) => $['newKnowledge.overview.when'])} {t(($) => $['newKnowledge.overview.activity'])} {t(($) => $['newKnowledge.overview.operator'])} @@ -838,10 +874,6 @@ function RecentActivity({ {formatWhen(activity.occurred_at)} - - {activityOperationLabel(activity, t)} - - {' — '} {activityLabel(activity, t)} @@ -862,31 +894,109 @@ function RecentActivity({ ) } +function ActivityDateRangePicker({ + dates, + onChange, +}: { + dates: ActivityDateRange + onChange: (dates: ActivityDateRange) => void +}) { + const { t, i18n } = useTranslation('dataset') + const today = dayjs() + const formatter = useMemo( + () => new Intl.DateTimeFormat(i18n.language, { day: 'numeric', month: 'short' }), + [i18n.language], + ) + const renderTrigger = + (edge: 'start' | 'end') => + ({ handleClickTrigger, isOpen, value }: TriggerProps) => ( +
$['newKnowledge.overview.timeRange'])} ${edge}`} + aria-expanded={isOpen} + className={cn( + 'min-w-0 flex-1 truncate rounded px-1 py-0.5 text-left system-xs-regular text-components-input-text-filled outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active', + isOpen && 'bg-state-base-hover', + )} + onClick={handleClickTrigger} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + handleClickTrigger(event as unknown as React.MouseEvent) + }} + > + {value ? formatter.format(value.toDate()) : '—'} +
+ ) + + return ( +
$['newKnowledge.overview.timeRange'])} + className="flex h-6 w-35 shrink-0 items-center rounded-lg bg-background-section px-1" + > + start && onChange({ end: dates.end, start: start.startOf('day') })} + onClear={() => undefined} + renderTrigger={renderTrigger('start')} + getIsDateDisabled={(date) => date.isAfter(today, 'day') || date.isAfter(dates.end, 'day')} + /> + + – + + end && onChange({ end: end.endOf('day'), start: dates.start })} + onClear={() => undefined} + renderTrigger={renderTrigger('end')} + getIsDateDisabled={(date) => + date.isAfter(today, 'day') || date.isBefore(dates.start, 'day') + } + /> +
+ ) +} + function ActivityDrawer({ activities, + dates, hasNextPage, isFetchingNextPage, loading, members, + onDatesChange, onFetchNextPage, onOpenChange, + onOperatorChange, onRangeChange, open, + operator, range, }: { activities: KnowledgeFsOverviewActivityResponse[] + dates: ActivityDateRange hasNextPage: boolean isFetchingNextPage: boolean loading: boolean members: Member[] + onDatesChange: (dates: ActivityDateRange) => void onFetchNextPage: () => void onOpenChange: (open: boolean) => void + onOperatorChange: (operator: ActivityOperator) => void onRangeChange: (range: ActivityRange) => void open: boolean + operator: ActivityOperator range: ActivityRange }) { const { t, i18n } = useTranslation('dataset') const { t: tDeployments } = useTranslation('deployments') + const { t: tActivityLog } = useTranslation('appLog') const rangeTriggerRef = useRef(null) const restoreFilterFocusRef = useRef(false) const now = dayjs() @@ -903,6 +1013,10 @@ function ActivityDrawer({ () => new Intl.DateTimeFormat(i18n.language, { hour: 'numeric', minute: '2-digit' }), [i18n.language], ) + const relativeTimeFormatter = useMemo( + () => new Intl.RelativeTimeFormat(i18n.language, { numeric: 'auto', style: 'narrow' }), + [i18n.language], + ) const groups = activities.reduce>( (result, task) => { const key = dayjs(task.occurred_at).format('YYYY-MM-DD') @@ -919,16 +1033,37 @@ function ActivityDrawer({ return t(($) => $['newKnowledge.overview.yesterday']) return dateFormatter.format(date.toDate()) } + const activityTime = (occurredAt: string) => { + const occurred = dayjs(occurredAt) + if (!occurred.isSame(now, 'day')) return timeFormatter.format(occurred.toDate()) + const elapsedMinutes = Math.max(0, now.diff(occurred, 'minute')) + if (elapsedMinutes < 60) return relativeTimeFormatter.format(-elapsedMinutes, 'minute') + return relativeTimeFormatter.format(-Math.floor(elapsedMinutes / 60), 'hour') + } const rangeLabel: Record = { '30d': t(($) => $['newKnowledge.overview.last30Days']), '7d': t(($) => $['newKnowledge.overview.last7Days']), '90d': t(($) => $['newKnowledge.overview.last90Days']), all: t(($) => $['newKnowledge.overview.allTime']), + custom: tActivityLog(($) => $['filter.period.custom']), today: t(($) => $['newKnowledge.overview.today']), } + const rangeTriggerLabel: Record = { + ...rangeLabel, + '30d': t(($) => $['newKnowledge.overview.thirtyDays']), + '7d': t(($) => $['newKnowledge.overview.sevenDays']), + '90d': '90d', + } + const operatorLabel = + operator === 'all' + ? tActivityLog(($) => $['filter.annotation.all']) + : operator === 'system' + ? t(($) => $['newKnowledge.overview.system']) + : members.find((member) => `member:${member.id}` === operator)?.name || operator.slice(7) const clearFilters = () => { restoreFilterFocusRef.current = true onRangeChange('7d') + onOperatorChange('all') } useEffect(() => { @@ -940,7 +1075,7 @@ function ActivityDrawer({ return ( - + @@ -952,7 +1087,7 @@ function ActivityDrawer({ -
+
+ {range === 'all' ? ( +
+ {rangeLabel.all} +
+ ) : ( + + )} +
{loading ? ( @@ -1030,7 +1201,7 @@ function ActivityDrawer({ className="shrink-0 system-xs-regular text-text-tertiary" dateTime={activity.occurred_at} > - {timeFormatter.format(new Date(activity.occurred_at))} + {activityTime(activity.occurred_at)} ))} @@ -1498,17 +1669,30 @@ export function KnowledgeOverviewPage({ knowledgeSpaceId }: { knowledgeSpaceId: const [window, setWindow] = useQueryState('window', overviewWindowParser) const [activityOpen, setActivityOpen] = useState(false) const [activityRange, setActivityRange] = useState('7d') - const activityFrom = useMemo(() => { - if (activityRange === 'all') return undefined - if (activityRange === 'today') return dayjs().startOf('day').toISOString() - return dayjs().subtract(Number.parseInt(activityRange), 'day').toISOString() - }, [activityRange]) + const [activityDates, setActivityDates] = useState(() => + activityDatesForRange('7d'), + ) + const [activityOperator, setActivityOperator] = useState('all') + const activityFrom = activityRange === 'all' ? undefined : activityDates.start.toISOString() + const activityTo = activityRange === 'all' ? undefined : activityDates.end.toISOString() + const activityActorType = + activityOperator === 'all' ? undefined : activityOperator === 'system' ? 'system' : 'member' + const activityActorId = activityOperator.startsWith('member:') + ? `dify-account:${activityOperator.slice(7)}` + : undefined + const handleActivityRangeChange = (range: ActivityRange) => { + setActivityRange(range) + if (range !== 'custom') setActivityDates(activityDatesForRange(range)) + } + const handleActivityDatesChange = (dates: ActivityDateRange) => { + setActivityDates(dates) + setActivityRange('custom') + } const membersQuery = useMembers() const tasksQuery = useInfiniteQuery( consoleQuery.knowledgeFs.spaces.byControlSpaceId.backgroundTasks.get.infiniteOptions({ getNextPageParam: (lastPage) => lastPage.next_cursor, initialPageParam: null as string | null, - queryKey: ['knowledge-fs-overview-activity', knowledgeSpaceId, activityRange], input: (pageParam) => ({ params: { control_space_id: knowledgeSpaceId }, query: { @@ -1592,12 +1776,22 @@ export function KnowledgeOverviewPage({ knowledgeSpaceId }: { knowledgeSpaceId: consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.activity.get.infiniteOptions({ getNextPageParam: (lastPage) => lastPage.next_cursor, initialPageParam: null as string | null, + queryKey: [ + 'knowledge-fs-overview-activity', + knowledgeSpaceId, + activityFrom, + activityTo, + activityOperator, + ], input: (pageParam) => ({ params: { control_space_id: knowledgeSpaceId }, query: { + ...(activityActorId ? { actor_id: activityActorId } : {}), + ...(activityActorType ? { actor_type: activityActorType } : {}), ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}), ...(activityFrom ? { from_at: activityFrom } : {}), limit: ACTIVITY_PAGE_SIZE, + ...(activityTo ? { to_at: activityTo } : {}), }, }), }), @@ -1818,15 +2012,19 @@ export function KnowledgeOverviewPage({ knowledgeSpaceId }: { knowledgeSpaceId:
void activityDrawerQuery.fetchNextPage()} onOpenChange={setActivityOpen} - onRangeChange={setActivityRange} + onOperatorChange={setActivityOperator} + onRangeChange={handleActivityRangeChange} /> )