diff --git a/api/client/client.go b/api/client/client.go index 73a1469493982..bdd0871eb8341 100644 --- a/api/client/client.go +++ b/api/client/client.go @@ -2796,7 +2796,7 @@ func (c *Client) UploadEncryptedRecording(ctx context.Context, sessionID string, } // SearchEvents allows searching for events with a full pagination support. -func (c *Client) SearchEvents(ctx context.Context, fromUTC, toUTC time.Time, namespace string, eventTypes []string, limit int, order types.EventOrder, startKey string, search string) ([]events.AuditEvent, string, error) { +func (c *Client) SearchEvents(ctx context.Context, fromUTC, toUTC time.Time, namespace string, eventTypes []string, limit int, order types.EventOrder, startKey string) ([]events.AuditEvent, string, error) { request := &proto.GetEventsRequest{ Namespace: namespace, StartDate: fromUTC, @@ -2805,7 +2805,6 @@ func (c *Client) SearchEvents(ctx context.Context, fromUTC, toUTC time.Time, nam Limit: int32(limit), StartKey: startKey, Order: proto.Order(order), - Search: search, } response, err := c.grpc.GetEvents(ctx, request) diff --git a/integrations/event-handler/helpers.go b/integrations/event-handler/helpers.go index c5744536e0927..d584c5de4770d 100644 --- a/integrations/event-handler/helpers.go +++ b/integrations/event-handler/helpers.go @@ -38,7 +38,7 @@ import ( type TeleportSearchEventsClient interface { export.Client // SearchEvents searches for events in the audit log and returns them using their protobuf representation. - SearchEvents(ctx context.Context, fromUTC, toUTC time.Time, namespace string, eventTypes []string, limit int, order types.EventOrder, startKey string, search string) ([]events.AuditEvent, string, error) + SearchEvents(ctx context.Context, fromUTC, toUTC time.Time, namespace string, eventTypes []string, limit int, order types.EventOrder, startKey string) ([]events.AuditEvent, string, error) // StreamSessionEvents returns session events stream for a given session ID using their protobuf representation. StreamSessionEvents(ctx context.Context, sessionID string, startIndex int64) (chan events.AuditEvent, chan error) // SearchUnstructuredEvents searches for events in the audit log and returns them using an unstructured representation (structpb.Struct). diff --git a/integrations/event-handler/legacy_events_watcher_test.go b/integrations/event-handler/legacy_events_watcher_test.go index a83933d85d5e5..bf02fb967066f 100644 --- a/integrations/event-handler/legacy_events_watcher_test.go +++ b/integrations/event-handler/legacy_events_watcher_test.go @@ -59,7 +59,7 @@ func (c *mockTeleportEventWatcher) setSearchEventsError(err error) { c.mockSearchErr = err } -func (c *mockTeleportEventWatcher) SearchEvents(ctx context.Context, fromUTC, toUTC time.Time, namespace string, eventTypes []string, limit int, order types.EventOrder, startKey string, search string) ([]events.AuditEvent, string, error) { +func (c *mockTeleportEventWatcher) SearchEvents(ctx context.Context, fromUTC, toUTC time.Time, namespace string, eventTypes []string, limit int, order types.EventOrder, startKey string) ([]events.AuditEvent, string, error) { c.mu.Lock() defer c.mu.Unlock() @@ -105,7 +105,7 @@ func (c *mockTeleportEventWatcher) StreamSessionEvents(ctx context.Context, sess } func (c *mockTeleportEventWatcher) SearchUnstructuredEvents(ctx context.Context, fromUTC, toUTC time.Time, namespace string, eventTypes []string, limit int, order types.EventOrder, startKey string) ([]*auditlogpb.EventUnstructured, string, error) { - events, lastKey, err := c.SearchEvents(ctx, fromUTC, toUTC, namespace, eventTypes, limit, order, startKey, "") + events, lastKey, err := c.SearchEvents(ctx, fromUTC, toUTC, namespace, eventTypes, limit, order, startKey) if err != nil { return nil, "", trace.Wrap(err) } diff --git a/integrations/lib/testing/integration/client.go b/integrations/lib/testing/integration/client.go index 761dfcb946e0c..1f85080b74181 100644 --- a/integrations/lib/testing/integration/client.go +++ b/integrations/lib/testing/integration/client.go @@ -109,7 +109,6 @@ func (api *Client) SearchAccessRequestEvents(ctx context.Context, reqID string) 100, types.EventOrderAscending, "", - "", ) result := make([]*events.AccessRequestCreate, 0, len(auditEvents)) for _, event := range auditEvents { diff --git a/lib/auth/authclient/clt.go b/lib/auth/authclient/clt.go index 800b3116b7245..a3cfbc89e1a1b 100644 --- a/lib/auth/authclient/clt.go +++ b/lib/auth/authclient/clt.go @@ -342,7 +342,7 @@ func (c *Client) StreamSessionEvents(ctx context.Context, sessionID session.ID, // SearchEvents allows searching for audit events with pagination support. func (c *Client) SearchEvents(ctx context.Context, req events.SearchEventsRequest) ([]apievents.AuditEvent, string, error) { - events, lastKey, err := c.APIClient.SearchEvents(ctx, req.From, req.To, apidefaults.Namespace, req.EventTypes, req.Limit, req.Order, req.StartKey, req.Search) + events, lastKey, err := c.APIClient.SearchEvents(ctx, req.From, req.To, apidefaults.Namespace, req.EventTypes, req.Limit, req.Order, req.StartKey) if err != nil { return nil, "", trace.Wrap(err) } diff --git a/lib/auth/grpcserver.go b/lib/auth/grpcserver.go index 056f28cc4023d..ee24a51dcb1cd 100644 --- a/lib/auth/grpcserver.go +++ b/lib/auth/grpcserver.go @@ -3929,7 +3929,6 @@ func (g *GRPCServer) GetEvents(ctx context.Context, req *authpb.GetEventsRequest Limit: int(req.Limit), Order: types.EventOrder(req.Order), StartKey: req.StartKey, - Search: req.Search, }) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/events/api.go b/lib/events/api.go index 5119914093f3a..3c36a4e4dca7b 100644 --- a/lib/events/api.go +++ b/lib/events/api.go @@ -1188,8 +1188,6 @@ type SearchEventsRequest struct { // If the previous response had LastKey set then this should be // set to its value. Otherwise leave empty. StartKey string - // Search is an optional search query to filter events. - Search string } type SearchSessionEventsRequest struct { diff --git a/lib/events/filelog.go b/lib/events/filelog.go index 54268a92ee2c5..38dc4066bdd86 100644 --- a/lib/events/filelog.go +++ b/lib/events/filelog.go @@ -195,7 +195,7 @@ func (l *FileLog) trimSizeAndMarshal(event apievents.AuditEvent) ([]byte, error) // This function may never return more than 1 MiB of event data. func (l *FileLog) SearchEvents(ctx context.Context, req SearchEventsRequest) ([]apievents.AuditEvent, string, error) { l.logger.DebugContext(ctx, "SearchEvents", "from", req.From, "to", req.To, "event_type", req.EventTypes, "limit", req.Limit) - return l.searchEventsWithFilter(req.From, req.To, req.Limit, req.Order, req.StartKey, searchEventsFilter{eventTypes: req.EventTypes, search: req.Search}) + return l.searchEventsWithFilter(req.From, req.To, req.Limit, req.Order, req.StartKey, searchEventsFilter{eventTypes: req.EventTypes}) } func (l *FileLog) searchEventsWithFilter(fromUTC, toUTC time.Time, limit int, order types.EventOrder, startAfter string, filter searchEventsFilter) ([]apievents.AuditEvent, string, error) { @@ -373,7 +373,6 @@ func (l *FileLog) GetEventExportChunks(ctx context.Context, req *auditlogpb.GetE type searchEventsFilter struct { eventTypes []string condition utils.FieldsCondition - search string } // Close closes the audit log, which includes closing all file handles and @@ -579,21 +578,6 @@ func (l *FileLog) findInFile(path string, filter searchEventsFilter) ([]EventFie if filter.condition != nil { accepted = accepted && filter.condition(utils.Fields(ef)) } - // Check if search filter matches. - if accepted && filter.search != "" { - eventJSON := strings.ToLower(string(scanner.Bytes())) - searchTerms := strings.Fields(strings.ToLower(filter.search)) - - matchedAll := true - for _, term := range searchTerms { - if !strings.Contains(eventJSON, term) { - matchedAll = false - break - } - } - - accepted = matchedAll - } if accepted { retval = append(retval, ef) @@ -621,11 +605,9 @@ type eventFile struct { // byDate implements sort.Interface. type byDate []eventFile -func (f byDate) Len() int { return len(f) } - +func (f byDate) Len() int { return len(f) } func (f byDate) Less(i, j int) bool { return f[i].ModTime().Before(f[j].ModTime()) } - -func (f byDate) Swap(i, j int) { f[i], f[j] = f[j], f[i] } +func (f byDate) Swap(i, j int) { f[i], f[j] = f[j], f[i] } // ByTimeAndIndex sorts events by time extracting timestamp from JSON field // and if there are several session events with the same session diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index e4e6b4016a823..e2d564b947dc8 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -872,16 +872,7 @@ func (h *Handler) bindDefaultEndpoints() { h.GET("/webapi/sites/:site/db/exec/ws", h.WithClusterAuthWebSocket(h.dbConnect)) // Audit events handlers. - // TODO (avatus): delete in v21 - // Deprecated: Use the v2 endpoint instead. - // - // clusterSearchEvents handles audit event retrieval for a given site. - // This legacy endpoint returns event listings without advanced search capabilities. - // Prefer using /v2/webapi/sites/:site/events/search for full query-based filtering. - h.GET("/webapi/sites/:site/events/search", h.WithClusterAuth(h.clusterSearchEvents)) // search site events - // clusterSearchEventsV2 handles audit event retrieval for a given site with support for - // advanced search filters and query parameters. - h.GET("/v2/webapi/sites/:site/events/search", h.WithClusterAuth(h.clusterSearchEventsV2)) // search site events + h.GET("/webapi/sites/:site/events/search", h.WithClusterAuth(h.clusterSearchEvents)) // search site events h.GET("/webapi/sites/:site/events/search/sessions", h.WithClusterAuth(h.clusterSearchSessionEvents)) // search site session events h.GET("/webapi/sites/:site/ttyplayback/:sid", h.WithClusterAuth(h.ttyPlaybackHandle)) @@ -4417,47 +4408,6 @@ func toFieldsSlice(rawEvents []apievents.AuditEvent) ([]events.EventFields, erro return el, nil } -// clusterSearchEventsV2 returns all audit log events matching the provided criteria -// -// GET /v2/webapi/sites/:site/events/search -// -// Query parameters: -// -// "from" : date range from, encoded as RFC3339 -// "to" : date range to, encoded as RFC3339 -// "limit" : optional maximum number of events to return on each fetch -// "startKey": resume events search from the last event received, -// empty string means start search from beginning -// "include" : optional comma-separated list of event names to return e.g. -// include=session.start,session.end, all are returned if empty -// "order": optional ordering of events. Can be either "asc" or "desc" -// for ascending and descending respectively. -// If no order is provided it defaults to descending. -// "search": optional search term to filter events by (case-insensitive substring match) -func (h *Handler) clusterSearchEventsV2(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, cluster reversetunnelclient.Cluster) (any, error) { - values := r.URL.Query() - - var eventTypes []string - if include := values.Get("include"); include != "" { - eventTypes = strings.Split(include, ",") - } - - search := values.Get("search") - - searchEvents := func(clt authclient.ClientI, from, to time.Time, limit int, order types.EventOrder, startKey string) ([]apievents.AuditEvent, string, error) { - return clt.SearchEvents(r.Context(), events.SearchEventsRequest{ - From: from, - To: to, - EventTypes: eventTypes, - Limit: limit, - Order: order, - StartKey: startKey, - Search: search, - }) - } - return clusterEventsList(r.Context(), sctx, cluster, r.URL.Query(), searchEvents) -} - // clusterSearchEvents returns all audit log events matching the provided criteria // // GET /v1/webapi/sites/:site/events/search @@ -4616,7 +4566,7 @@ func QueryLimitAsInt32(query url.Values, name string, def int32) (int32, error) // queryOrder returns the order parameter with the specified name from the // query string or a default if the parameter is not provided. func queryOrder(query url.Values, name string, def types.EventOrder) (types.EventOrder, error) { - value := strings.ToLower(query.Get(name)) + value := query.Get(name) switch value { case "desc": return types.EventOrderDescending, nil diff --git a/web/packages/teleport/src/Audit/Audit.story.tsx b/web/packages/teleport/src/Audit/Audit.story.tsx index bed6f946701d8..26bcb51268764 100644 --- a/web/packages/teleport/src/Audit/Audit.story.tsx +++ b/web/packages/teleport/src/Audit/Audit.story.tsx @@ -67,10 +67,9 @@ export const Failed = () => { export const AllPossibleEvents = () => ( null} - setSort={() => null} - sort={{ dir: 'ASC', fieldName: 'created' }} + fetchMore={() => null} + fetchStatus={''} + pageSize={1000} /> ); diff --git a/web/packages/teleport/src/Audit/Audit.test.tsx b/web/packages/teleport/src/Audit/Audit.test.tsx deleted file mode 100644 index 1d5775e2ae4b2..0000000000000 --- a/web/packages/teleport/src/Audit/Audit.test.tsx +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Teleport - * Copyright (C) 2025 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import { QueryClientProvider } from '@tanstack/react-query'; -import { createMemoryHistory } from 'history'; -import { mockIntersectionObserver } from 'jsdom-testing-mocks'; -import { PropsWithChildren } from 'react'; -import { MemoryRouter, Route, Router } from 'react-router'; - -import { darkTheme } from 'design/theme'; -import { ConfiguredThemeProvider } from 'design/ThemeProvider'; -import { - act, - render, - screen, - testQueryClient, - userEvent, -} from 'design/utils/testing'; - -import cfg from 'teleport/config'; -import { createTeleportContext } from 'teleport/mocks/contexts'; -import { makeEvent } from 'teleport/services/audit'; -import TeleportContext from 'teleport/teleportContext'; - -import { ContextProvider } from '..'; -import { AuditContainer } from './Audit'; - -const mio = mockIntersectionObserver(); - -describe('Audit', () => { - afterEach(() => { - testQueryClient.clear(); - }); - - it('adds search to URL when searching', async () => { - const ctx = createTeleportContext(); - jest - .spyOn(ctx.auditService, 'fetchEventsV2') - .mockResolvedValue({ events: [], startKey: '' }); - jest.spyOn(ctx.clusterService, 'fetchClusters').mockResolvedValue([]); - - const { history, user } = renderComponent(ctx); - jest.spyOn(history, 'push'); - act(mio.enterAll); - - const search = await screen.findByPlaceholderText('Search...'); - await user.type(search, 'test-search'); - await user.type(search, '{enter}'); - - expect(history.push).toHaveBeenCalledWith({ - pathname: '/web/cluster/root/audit', - search: expect.stringContaining('search=test-search'), - }); - }); - - it('sets sort direction when clicking table header', async () => { - const ctx = createTeleportContext(); - const mockEvent = { - codeDesc: 'Local Login', - message: 'Local user [root] successfully logged in', - id: 'user.login:2021-05-25T14:37:27.848Z', - code: 'T1000I', - user: 'root', - time: new Date('2021-05-25T14:37:27.848Z'), - raw: { - cluster_name: 'im-a-cluster-name', - code: 'T1000I', - ei: 0, - event: 'user.login', - method: 'local', - success: true, - time: '2021-05-25T14:37:27.848Z', - user: 'root', - }, - }; - - jest - .spyOn(ctx.auditService, 'fetchEventsV2') - .mockResolvedValue({ events: [makeEvent(mockEvent)], startKey: '' }); - jest.spyOn(ctx.clusterService, 'fetchClusters').mockResolvedValue([]); - - const { history, user } = renderComponent(ctx); - jest.spyOn(history, 'replace'); - act(mio.enterAll); - - const timeHeader = await screen.findByText(/Created \(UTC\)/i); - await user.click(timeHeader); - - expect(history.replace).toHaveBeenCalledWith({ - pathname: '/web/cluster/root/audit', - search: expect.stringContaining('order=ASC'), - }); - }); -}); - -function renderComponent(ctx: TeleportContext) { - const user = userEvent.setup(); - const history = createMemoryHistory({ - initialEntries: ['/web/cluster/root/audit'], - }); - - return { - ...render(, { - wrapper: makeWrapper({ history, ctx }), - }), - user, - history, - }; -} - -function makeWrapper({ - history, - ctx, -}: { - history: ReturnType; - ctx: TeleportContext; -}) { - return ({ children }: PropsWithChildren) => { - return ( - - - - - - {children} - - - - - - ); - }; -} diff --git a/web/packages/teleport/src/Audit/Audit.tsx b/web/packages/teleport/src/Audit/Audit.tsx index a4ff8c82ab9b2..7ea1e54bb1dc4 100644 --- a/web/packages/teleport/src/Audit/Audit.tsx +++ b/web/packages/teleport/src/Audit/Audit.tsx @@ -16,13 +16,10 @@ * along with this program. If not, see . */ -import { useState, type PropsWithChildren } from 'react'; -import styled from 'styled-components'; +import { useState } from 'react'; -import { Box, ButtonSecondary, Flex } from 'design'; +import { Box, Indicator } from 'design'; import { Danger } from 'design/Alert'; -import { SearchPanel } from 'shared/components/Search'; -import { useInfiniteScroll } from 'shared/hooks'; import { ExternalAuditStorageCta } from '@gravitational/teleport/src/components/ExternalAuditStorageCta'; import { ClusterDropdown } from 'teleport/components/ClusterDropdown/ClusterDropdown'; @@ -36,7 +33,6 @@ import useStickyClusterId from 'teleport/useStickyClusterId'; import useTeleport from 'teleport/useTeleport'; import EventList from './EventList'; -import { EventListSkeleton } from './EventListSkeleton'; import useAuditEvents, { State } from './useAuditEvents'; export function AuditContainer() { @@ -48,63 +44,31 @@ export function AuditContainer() { export function Audit(props: State) { const { + attempt, range, setRange, + rangeOptions, events, clusterId, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - error, - isLoading, - search, - setSearch, - sort, - setSort, + fetchMore, + fetchStatus, ctx, - refetch, - isError, } = props; - const [errorMessage, setErrorMessage] = useState(''); - const { setTrigger } = useInfiniteScroll({ - fetch: async () => { - if (hasNextPage && !isFetchingNextPage && !isError) { - fetchNextPage(); - } - }, - }); - - const onRetryClicked = () => { - refetch(); - }; - - const onLoadMoreClicked = () => { - if (hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } - }; - return ( - + Audit Log - + - {!isLoading && isError && error && ( - - - {error.message} - - - )} + {attempt.status === 'failed' && {attempt.statusText} } {!errorMessage && ( )} {errorMessage && {errorMessage}} - - + + + )} + {attempt.status === 'success' && ( + - {!isLoading && ( - - )} - {((isLoading && events.length === 0) || isFetchingNextPage) && ( - - )} -
- {isError && events.length > 0 && !isLoading && ( - - - Load more - - - )} - + )} ); } - -function ErrorsContainer(props: PropsWithChildren) { - return {props.children}; -} - -const ErrorBox = styled(Flex)` - position: sticky; - flex-direction: column; - top: ${props => props.theme.space[3]}px; - gap: ${props => props.theme.space[1]}px; - padding-top: ${props => props.theme.space[1]}px; - padding-bottom: ${props => props.theme.space[3]}px; - z-index: 1; -`; - -const DangerWithBackground = styled(Danger)` - background: ${props => props.theme.colors.levels.sunken}; -`; diff --git a/web/packages/teleport/src/Audit/EventList/EventList.tsx b/web/packages/teleport/src/Audit/EventList/EventList.tsx index c744f3e96e14e..b643d5d196d42 100644 --- a/web/packages/teleport/src/Audit/EventList/EventList.tsx +++ b/web/packages/teleport/src/Audit/EventList/EventList.tsx @@ -20,6 +20,7 @@ import { useState } from 'react'; import { ButtonBorder, Flex } from 'design'; import Table, { Cell } from 'design/DataTable'; +import { dateTimeMatcher } from 'design/utils/match'; import { Event } from 'teleport/services/audit'; @@ -29,7 +30,7 @@ import renderTypeCell from './EventTypeCell'; import { ViewInPolicyButton } from './ViewInPolicyButton'; export default function EventList(props: Props) { - const { events = [], sort, setSort } = props; + const { events = [], fetchMore, fetchStatus, pageSize = 50 } = props; const [detailsToShow, setDetailsToShow] = useState(); return ( <> @@ -39,7 +40,7 @@ export default function EventList(props: Props) { { key: 'codeDesc', headerText: 'Type', - isSortable: false, + isSortable: true, render: event => renderTypeCell(event), }, { @@ -59,10 +60,14 @@ export default function EventList(props: Props) { }, ]} emptyText={'No Events Found'} - customSort={{ - fieldName: sort.fieldName, - dir: sort.dir, - onSort: setSort, + isSearchable + searchableProps={['code', 'codeDesc', 'time', 'user', 'message', 'id']} + customSearchMatchers={[dateTimeMatcher(['time'])]} + initialSort={{ key: 'time', dir: 'DESC' }} + pagination={{ pageSize }} + fetching={{ + onFetchMore: fetchMore, + fetchStatus, }} /> {detailsToShow && ( @@ -103,8 +108,7 @@ export function renderDescCell({ message }: Event) { type Props = { events: State['events']; - search: State['search']; - setSearch: State['setSearch']; - sort: State['sort']; - setSort: State['setSort']; + fetchMore: State['fetchMore']; + fetchStatus: State['fetchStatus']; + pageSize?: number; }; diff --git a/web/packages/teleport/src/Audit/EventListSkeleton.tsx b/web/packages/teleport/src/Audit/EventListSkeleton.tsx deleted file mode 100644 index d487e5dc45640..0000000000000 --- a/web/packages/teleport/src/Audit/EventListSkeleton.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Teleport - * Copyright (C) 2025 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import { useState } from 'react'; -import styled from 'styled-components'; - -import { Flex } from 'design'; -import { ShimmerBox } from 'design/ShimmerBox'; -import { LoadingSkeleton } from 'shared/components/UnifiedResources/shared/LoadingSkeleton'; - -export function EventListSkeleton() { - return } />; -} - -function LoadingEventRow() { - const [randomizedSize] = useState(() => ({ - type: randomNum(60, 40), - description: randomNum(80, 50), - time: randomNum(100, 80), - })); - - return ( - - {/* Type column */} - - - - - {/* Description column */} - - - - - {/* Created time column */} - - - - - {/* Action buttons column */} - - - - - ); -} - -function randomNum(max: number, min: number) { - return Math.floor(Math.random() * (max - min + 1)) + min; -} - -const LoadingRow = styled(Flex)` - border-bottom: 1px solid ${props => props.theme.colors.spotBackground[0]}; -`; diff --git a/web/packages/teleport/src/Audit/useAuditEvents.ts b/web/packages/teleport/src/Audit/useAuditEvents.ts index de06915c2787d..bfd248755bfb9 100644 --- a/web/packages/teleport/src/Audit/useAuditEvents.ts +++ b/web/packages/teleport/src/Audit/useAuditEvents.ts @@ -16,161 +16,97 @@ * along with this program. If not, see . */ -import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'; -import { endOfDay, startOfDay } from 'date-fns'; -import { useCallback, useMemo } from 'react'; -import { useHistory, useLocation } from 'react-router'; +import { useEffect, useMemo, useState } from 'react'; -import type { SortDir, SortType } from 'design/DataTable/types'; +import useAttempt from 'shared/hooks/useAttemptNext'; -import { EventRange } from 'teleport/components/EventRangePicker'; -import { EventCode, formatters } from 'teleport/services/audit'; +import { + EventRange, + getRangeOptions, +} from 'teleport/components/EventRangePicker'; +import { Event, EventCode, formatters } from 'teleport/services/audit'; import Ctx from 'teleport/teleportContext'; -const PAGE_SIZE = 50; - export default function useAuditEvents( ctx: Ctx, clusterId: string, eventCode?: EventCode ) { - const history = useHistory(); - const location = useLocation(); - - const queryParams = useMemo( - () => new URLSearchParams(location.search), - [location.search] - ); - - const fromParam = queryParams.get('from'); - const toParam = queryParams.get('to'); - const search = queryParams.get('search') || ''; - const orderParam = queryParams.get('order'); - const sortDir: SortDir = orderParam?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC'; - - const filterBy = eventCode ? formatters[eventCode].type : ''; - - const { - data, - error, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading, - isSuccess, - refetch, - isError, - } = useInfiniteQuery({ - queryKey: [ - 'audit_events', - clusterId, - fromParam, - toParam, - filterBy, - search, - sortDir, - ], - queryFn: ({ pageParam, signal }) => - ctx.auditService.fetchEventsV2( - clusterId, - { - from: fromParam ? startOfDay(new Date(fromParam)) : undefined, - to: toParam ? endOfDay(new Date(toParam)) : undefined, - filterBy, - startKey: pageParam, - limit: PAGE_SIZE, - search, - order: sortDir, - }, - signal - ), - initialPageParam: '', - getNextPageParam: lastPage => lastPage.startKey || undefined, - placeholderData: keepPreviousData, - staleTime: 30_000, + const rangeOptions = useMemo(() => getRangeOptions(), []); + const [range, setRange] = useState(rangeOptions[0]); + const { attempt, setAttempt, run } = useAttempt('processing'); + const [results, setResults] = useState({ + events: [], + fetchStartKey: '', + fetchStatus: '', }); + const filterBy = eventCode ? formatters[eventCode].type : ''; - // Flatten all pages into a single array for infinite scroll - const events = useMemo(() => { - if (!data || data.pages.length === 0) { - return []; - } - return data.pages.flatMap(page => page.events); - }, [data]); - - const setRange = useCallback( - (newRange: EventRange) => { - const params = new URLSearchParams(location.search); - params.set('from', newRange.from.toISOString()); - params.set('to', newRange.to.toISOString()); - - history.push({ - pathname: location.pathname, - search: params.toString(), - }); - }, - [history, location] - ); - - const setSearch = useCallback( - (newSearch: string) => { - const params = new URLSearchParams(location.search); - if (newSearch) { - params.set('search', newSearch); - } else { - params.delete('search'); - } - - history.push({ - pathname: location.pathname, - search: params.toString(), - }); - }, - [history, location] - ); - - const setSort = useCallback( - (nextSort: SortType) => { - const params = new URLSearchParams(location.search); - const nextDir: SortDir = nextSort.dir === 'ASC' ? 'ASC' : 'DESC'; - params.set('order', nextDir); - - history.replace({ - pathname: location.pathname, - search: params.toString(), + useEffect(() => { + fetch(); + }, [clusterId, range]); + + // fetchMore gets events from last position from + // last fetch, indicated by startKey. The response is + // appended to existing events list. + function fetchMore() { + setResults({ + ...results, + fetchStatus: 'loading', + }); + ctx.auditService + .fetchEvents(clusterId, { + ...range, + filterBy, + startKey: results.fetchStartKey, + }) + .then(res => + setResults({ + events: [...results.events, ...res.events], + fetchStartKey: res.startKey, + fetchStatus: res.startKey ? '' : 'disabled', + }) + ) + .catch((err: Error) => { + setAttempt({ status: 'failed', statusText: err.message }); }); - }, - [history, location] - ); - - const sort: SortType = { fieldName: 'time', dir: sortDir }; + } + + // fetch gets events from beginning of range and + // replaces existing events list. + function fetch() { + run(() => + ctx.auditService + .fetchEvents(clusterId, { + ...range, + filterBy, + }) + .then(res => + setResults({ + events: res.events, + fetchStartKey: res.startKey, + fetchStatus: res.startKey ? '' : 'disabled', + }) + ) + ); + } return { - events, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading, - error, - isSuccess, - refetch, - isError, + ...results, + fetchMore, clusterId, - range: - fromParam && toParam - ? { - from: new Date(fromParam), - to: new Date(toParam), - isCustom: true, - } - : undefined, + attempt, + range, setRange, - search, - setSearch, - sort, - setSort, + rangeOptions, ctx, }; } +type EventResult = { + events: Event[]; + fetchStatus: 'loading' | 'disabled' | ''; + fetchStartKey: string; +}; + export type State = ReturnType; diff --git a/web/packages/teleport/src/Recordings/Recordings.tsx b/web/packages/teleport/src/Recordings/Recordings.tsx index 673eb482361ba..3fd2e9d832938 100644 --- a/web/packages/teleport/src/Recordings/Recordings.tsx +++ b/web/packages/teleport/src/Recordings/Recordings.tsx @@ -46,6 +46,7 @@ export function Recordings({ fetchMore, range, setRange, + rangeOptions, attempt, clusterId, ctx, @@ -55,7 +56,12 @@ export function Recordings({ Session Recordings - + {!errorMessage && ( diff --git a/web/packages/teleport/src/SessionRecordings/list/ListSessionRecordingsRoute.tsx b/web/packages/teleport/src/SessionRecordings/list/ListSessionRecordingsRoute.tsx index e602688be3a15..13d1768d5f78b 100644 --- a/web/packages/teleport/src/SessionRecordings/list/ListSessionRecordingsRoute.tsx +++ b/web/packages/teleport/src/SessionRecordings/list/ListSessionRecordingsRoute.tsx @@ -125,6 +125,7 @@ export function ListSessionRecordings({ diff --git a/web/packages/teleport/src/components/EventRangePicker/EventRangePicker.story.tsx b/web/packages/teleport/src/components/EventRangePicker/EventRangePicker.story.tsx index fba2357f09a1b..14812b2384ac5 100644 --- a/web/packages/teleport/src/components/EventRangePicker/EventRangePicker.story.tsx +++ b/web/packages/teleport/src/components/EventRangePicker/EventRangePicker.story.tsx @@ -29,5 +29,7 @@ export const Picker = () => { const rangeOptions = getRangeOptions(); const [range, setRange] = useState(rangeOptions[0]); - return ; + return ( + + ); }; diff --git a/web/packages/teleport/src/components/EventRangePicker/EventRangePicker.tsx b/web/packages/teleport/src/components/EventRangePicker/EventRangePicker.tsx index 11d365e81b23c..9ba01ee685185 100644 --- a/web/packages/teleport/src/components/EventRangePicker/EventRangePicker.tsx +++ b/web/packages/teleport/src/components/EventRangePicker/EventRangePicker.tsx @@ -17,28 +17,44 @@ */ import { useState } from 'react'; +import { components, ValueContainerProps } from 'react-select'; import 'react-day-picker/dist/style.css'; import styled from 'styled-components'; -import { Box, ButtonBorder, Text } from 'design'; +import { Box, Text } from 'design'; import { displayDate } from 'design/datetime'; import Dialog from 'design/DialogConfirmation'; -import { Calendar } from 'design/Icon'; +import Select, { Option } from 'shared/components/Select'; import { useRefClickOutside } from 'shared/hooks/useRefClickOutside'; +import { State } from 'teleport/Audit/useAuditEvents'; + import { CustomRange } from './Custom'; import { EventRange } from './utils'; -export default function DateRange({ ml, range, onChangeRange }: Props) { +type RangeOption = Option; + +export default function DataRange({ ml, range, onChangeRange, ranges }: Props) { const [isPickerOpen, openDayPicker] = useState(false); + const [rangeOptions] = useState(() => + ranges.map(range => ({ value: range, label: range.name })) + ); const dayPickerRef = useRefClickOutside({ open: isPickerOpen, setOpen: openDayPicker, }); + function handleOnChange(option: Option) { + if (option.value.isCustom) { + openDayPicker(true); + } else { + onChangeRange(option.value); + } + } + function onClosePicker() { openDayPicker(false); } @@ -50,15 +66,14 @@ export default function DateRange({ ml, range, onChangeRange }: Props) { return ( <> - - openDayPicker(true)}> - - {range ? ( - {`${displayDate(range.from)} - ${displayDate(range.to)}`} - ) : ( - Select date range... - )} - + +