diff --git a/frontend/src/api.ts b/frontend/src/api.ts index aace68a150..2dea526601 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -54,6 +54,11 @@ export const API = { PORTAL_SESSION: (username: string) => `${API.USER_BILLING.BASE(username)}/portal_session`, }, + EVENTS: { + BASE: () => `${API.BASE()}/events`, + LIST: () => `${API.EVENTS.BASE()}/list`, + }, + PROJECTS: { BASE: () => `${API.BASE()}/projects`, LIST: () => `${API.PROJECTS.BASE()}/list`, diff --git a/frontend/src/layouts/AppLayout/hooks.ts b/frontend/src/layouts/AppLayout/hooks.ts index ef53a0082e..a305317d50 100644 --- a/frontend/src/layouts/AppLayout/hooks.ts +++ b/frontend/src/layouts/AppLayout/hooks.ts @@ -29,6 +29,7 @@ export const useSideNavigation = () => { { type: 'link', text: t('navigation.fleets'), href: ROUTES.FLEETS.LIST }, { type: 'link', text: t('navigation.instances'), href: ROUTES.INSTANCES.LIST }, { type: 'link', text: t('navigation.volumes'), href: ROUTES.VOLUMES.LIST }, + { type: 'link', text: t('navigation.events'), href: ROUTES.EVENTS.LIST }, { type: 'link', text: t('navigation.project_other'), href: ROUTES.PROJECT.LIST }, isGlobalAdmin && { diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 3993c7f8a7..3281ba8f4c 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -82,7 +82,8 @@ "resources": "Resources", "volumes": "Volumes", "instances": "Instances", - "offers": "Offers" + "offers": "Offers", + "events": "Events" }, "backend": { @@ -640,6 +641,13 @@ } }, + "events": { + "recorded_at": "Recorded At", + "actor": "Actor", + "targets": "Targets", + "message": "Message" + }, + "users": { "page_title": "Users", "search_placeholder": "Find members", diff --git a/frontend/src/pages/Events/List/helpers.ts b/frontend/src/pages/Events/List/helpers.ts new file mode 100644 index 0000000000..702c8a5617 --- /dev/null +++ b/frontend/src/pages/Events/List/helpers.ts @@ -0,0 +1,23 @@ +import type { PropertyFilterProps } from 'components'; + +export function filterLastElementByPrefix( + arr: PropertyFilterProps.Query['tokens'], + prefix: string, +): PropertyFilterProps.Query['tokens'] { + // Ищем индекс последнего элемента с префиксом "test_" + let lastTestIndex = -1; + for (let i = arr.length - 1; i >= 0; i--) { + if (arr[i].propertyKey?.startsWith(prefix)) { + lastTestIndex = i; + break; + } + } + + // Фильтруем массив + return arr.filter((item, index) => { + // Оставляем элемент, если: + // 1. Это не строка с префиксом "test_"? + // 2. ИЛИ это строка с префиксом "test_" И она последняя в массиве + return !item.propertyKey?.startsWith(prefix) || index === lastTestIndex; + }); +} diff --git a/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx b/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx new file mode 100644 index 0000000000..88e067814e --- /dev/null +++ b/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { format } from 'date-fns'; + +import { NavigateLink, TableProps } from 'components'; + +import { DATE_TIME_FORMAT } from 'consts'; +import { ROUTES } from 'routes'; + +export const useColumnsDefinitions = () => { + const { t } = useTranslation(); + + const columns: TableProps.ColumnDefinition[] = [ + { + id: 'recorded_at', + header: t('events.recorded_at'), + cell: (item) => format(new Date(item.recorded_at), DATE_TIME_FORMAT), + }, + { + id: 'actor', + header: t('events.actor'), + cell: (item) => + item.actor_user ? ( + {item.actor_user} + ) : ( + '-' + ), + }, + { + id: 'target', + header: t('events.targets'), + cell: (item) => { + return item.targets.map((target) => { + switch (target.type) { + case 'project': + return ( +
+ Project{' '} + {target.project_name && ( + + {target.project_name} + + )} +
+ ); + + case 'fleet': + return ( +
+ Fleet{' '} + {target.project_name && ( + + {target.project_name} + + )} + / + + {target.name} + +
+ ); + + case 'user': + return ( +
+ User{' '} + {target.name} +
+ ); + + case 'instance': + return ( +
+ Instance{' '} + {target.project_name && ( + + {target.project_name} + + )} + /{target.name} +
+ ); + + case 'run': + return ( +
+ Run{' '} + {target.project_name && ( + + {target.project_name} + + )} + / + + {target.name} + +
+ ); + + case 'job': + return ( +
+ Job{' '} + {target.project_name && ( + + {target.project_name} + + )} + /{target.name} +
+ ); + + default: + return '---'; + } + }); + }, + }, + { + id: 'message', + header: t('events.message'), + cell: ({ message }) => message, + }, + ]; + + return { columns } as const; +}; diff --git a/frontend/src/pages/Events/List/hooks/useFilters.ts b/frontend/src/pages/Events/List/hooks/useFilters.ts new file mode 100644 index 0000000000..5ef714c763 --- /dev/null +++ b/frontend/src/pages/Events/List/hooks/useFilters.ts @@ -0,0 +1,259 @@ +import { useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import type { PropertyFilterProps } from 'components'; + +import { EMPTY_QUERY, requestParamsToTokens, tokensToRequestParams, tokensToSearchParams } from 'libs/filters'; +import { useGetProjectsQuery } from 'services/project'; +import { useGetUserListQuery } from 'services/user'; + +import { filterLastElementByPrefix } from '../helpers'; + +type RequestParamsKeys = keyof Pick< + TEventListRequestParams, + | 'target_projects' + | 'target_users' + | 'target_fleets' + | 'target_instances' + | 'target_runs' + | 'target_jobs' + | 'within_projects' + | 'within_fleets' + | 'within_runs' + | 'include_target_types' + | 'actors' +>; + +const filterKeys: Record = { + TARGET_PROJECTS: 'target_projects', + TARGET_USERS: 'target_users', + TARGET_FLEETS: 'target_fleets', + TARGET_INSTANCES: 'target_instances', + TARGET_RUNS: 'target_runs', + TARGET_JOBS: 'target_jobs', + WITHIN_PROJECTS: 'within_projects', + WITHIN_FLEETS: 'within_fleets', + WITHIN_RUNS: 'within_runs', + INCLUDE_TARGET_TYPES: 'include_target_types', + ACTORS: 'actors', +}; + +const onlyOneFilterGroupPrefixes = ['target_', 'within_']; + +const multipleChoiseKeys: RequestParamsKeys[] = [ + 'target_projects', + 'target_users', + 'target_fleets', + 'target_instances', + 'target_runs', + 'target_jobs', + 'within_projects', + 'within_fleets', + 'within_runs', + 'include_target_types', + 'actors', +]; + +const targetTypes = ['project', 'user', 'fleet', 'instance', 'run', 'job']; + +export const useFilters = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const { data: projectsData } = useGetProjectsQuery(); + const { data: usersData } = useGetUserListQuery(); + + const [propertyFilterQuery, setPropertyFilterQuery] = useState(() => + requestParamsToTokens({ searchParams, filterKeys }), + ); + + const clearFilter = () => { + setSearchParams({}); + setPropertyFilterQuery(EMPTY_QUERY); + }; + + const filteringOptions = useMemo(() => { + const options: PropertyFilterProps.FilteringOption[] = []; + + projectsData?.forEach(({ project_name }) => { + options.push({ + propertyKey: filterKeys.TARGET_PROJECTS, + value: project_name, + }); + + options.push({ + propertyKey: filterKeys.WITHIN_PROJECTS, + value: project_name, + }); + }); + + usersData?.forEach(({ username }) => { + options.push({ + propertyKey: filterKeys.TARGET_USERS, + value: username, + }); + + options.push({ + propertyKey: filterKeys.ACTORS, + value: username, + }); + }); + + targetTypes?.forEach((targetType) => { + options.push({ + propertyKey: filterKeys.INCLUDE_TARGET_TYPES, + value: targetType, + }); + }); + + return options; + }, [projectsData, usersData]); + + const setSearchParamsHandle = ({ tokens }: { tokens: PropertyFilterProps.Query['tokens'] }) => { + const searchParams = tokensToSearchParams(tokens); + + setSearchParams(searchParams); + }; + + const filteringProperties = [ + { + key: filterKeys.TARGET_PROJECTS, + operators: ['='], + propertyLabel: 'Target Projects', + groupValuesLabel: 'Project ids', + }, + { + key: filterKeys.TARGET_USERS, + operators: ['='], + propertyLabel: 'Target Users', + groupValuesLabel: 'Project ids', + }, + { + key: filterKeys.TARGET_FLEETS, + operators: ['='], + propertyLabel: 'Target Fleets', + }, + { + key: filterKeys.TARGET_INSTANCES, + operators: ['='], + propertyLabel: 'Target Instances', + }, + { + key: filterKeys.TARGET_RUNS, + operators: ['='], + propertyLabel: 'Target Runs', + }, + { + key: filterKeys.TARGET_JOBS, + operators: ['='], + propertyLabel: 'Target Jobs', + }, + + { + key: filterKeys.WITHIN_PROJECTS, + operators: ['='], + propertyLabel: 'Within Projects', + groupValuesLabel: 'Project ids', + }, + + { + key: filterKeys.WITHIN_FLEETS, + operators: ['='], + propertyLabel: 'Within Fleets', + }, + + { + key: filterKeys.WITHIN_RUNS, + operators: ['='], + propertyLabel: 'Within Runs', + }, + + { + key: filterKeys.INCLUDE_TARGET_TYPES, + operators: ['='], + propertyLabel: 'Target types', + groupValuesLabel: 'Target type values', + }, + + { + key: filterKeys.ACTORS, + operators: ['='], + propertyLabel: 'Actors', + }, + ]; + + const onChangePropertyFilterHandle = ({ tokens, operation }: PropertyFilterProps.Query) => { + let filteredTokens = [...tokens]; + + onlyOneFilterGroupPrefixes.forEach((prefix) => { + try { + filteredTokens = filterLastElementByPrefix(filteredTokens, prefix); + } catch (_) { + console.error(_); + } + }); + + setSearchParamsHandle({ tokens: filteredTokens }); + + setPropertyFilterQuery({ + operation, + tokens: filteredTokens, + }); + }; + + const onChangePropertyFilter: PropertyFilterProps['onChange'] = ({ detail }) => { + onChangePropertyFilterHandle(detail); + }; + + const filteringRequestParams = useMemo(() => { + const params = tokensToRequestParams({ + tokens: propertyFilterQuery.tokens, + arrayFieldKeys: multipleChoiseKeys, + }); + + const mappedFields = { + ...(params[filterKeys.TARGET_PROJECTS] && Array.isArray(params[filterKeys.TARGET_PROJECTS]) + ? { + [filterKeys.TARGET_PROJECTS]: params[filterKeys.TARGET_PROJECTS]?.map( + (name: string) => projectsData?.find(({ project_name }) => project_name === name)?.['project_id'], + ), + } + : {}), + ...(params[filterKeys.WITHIN_PROJECTS] && Array.isArray(params[filterKeys.WITHIN_PROJECTS]) + ? { + [filterKeys.WITHIN_PROJECTS]: params[filterKeys.WITHIN_PROJECTS]?.map( + (name: string) => projectsData?.find(({ project_name }) => project_name === name)?.['project_id'], + ), + } + : {}), + + ...(params[filterKeys.TARGET_USERS] && Array.isArray(params[filterKeys.TARGET_USERS]) + ? { + [filterKeys.TARGET_USERS]: params[filterKeys.TARGET_USERS]?.map( + (name: string) => usersData?.find(({ username }) => username === name)?.['id'], + ), + } + : {}), + + ...(params[filterKeys.ACTORS] && Array.isArray(params[filterKeys.ACTORS]) + ? { + [filterKeys.ACTORS]: params[filterKeys.ACTORS]?.map( + (name: string) => usersData?.find(({ username }) => username === name)?.['id'], + ), + } + : {}), + }; + + return { + ...params, + ...mappedFields, + } as Partial; + }, [propertyFilterQuery, usersData, projectsData]); + + return { + filteringRequestParams, + clearFilter, + propertyFilterQuery, + onChangePropertyFilter, + filteringOptions, + filteringProperties, + } as const; +}; diff --git a/frontend/src/pages/Events/List/index.tsx b/frontend/src/pages/Events/List/index.tsx new file mode 100644 index 0000000000..fcf979d554 --- /dev/null +++ b/frontend/src/pages/Events/List/index.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button, Header, Loader, PropertyFilter, SpaceBetween, Table } from 'components'; + +import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; +import { useBreadcrumbs, useInfiniteScroll } from 'hooks'; +import { useCollection } from 'hooks'; +import { ROUTES } from 'routes'; +import { useLazyGetAllEventsQuery } from 'services/events'; + +import { useColumnsDefinitions } from './hooks/useColumnDefinitions'; +import { useFilters } from './hooks/useFilters'; + +import styles from '../../Runs/List/styles.module.scss'; + +export const EventList = () => { + const { t } = useTranslation(); + + useBreadcrumbs([ + { + text: t('navigation.events'), + href: ROUTES.EVENTS.LIST, + }, + ]); + + const { filteringRequestParams, propertyFilterQuery, onChangePropertyFilter, filteringOptions, filteringProperties } = + useFilters(); + + const { data, isLoading, refreshList, isLoadingMore } = useInfiniteScroll({ + useLazyQuery: useLazyGetAllEventsQuery, + args: { ...filteringRequestParams, limit: DEFAULT_TABLE_PAGE_SIZE }, + + getPaginationParams: (lastEvent) => ({ + prev_recorded_at: lastEvent.recorded_at, + prev_id: lastEvent.id, + }), + }); + + const { items, collectionProps } = useCollection(data, { + filtering: { + // empty: renderEmptyMessage(), + // noMatch: renderNoMatchMessage(), + }, + selection: {}, + }); + + const { columns } = useColumnsDefinitions(); + + return ( + +