diff --git a/lib/events/athena/integration_test.go b/lib/events/athena/integration_test.go index a38e1a97622..d7a68e48b45 100644 --- a/lib/events/athena/integration_test.go +++ b/lib/events/athena/integration_test.go @@ -155,6 +155,17 @@ func TestIntegrationAthenaEventPagination(t *testing.T) { }) } +func TestIntegrationAthenaSearchEventsBySearchTerm(t *testing.T) { + t.Run("sns", func(t *testing.T) { + const bypassSNSFalse = false + testIntegrationAthenaSearchEventsBySearchTerm(t, bypassSNSFalse) + }) + t.Run("sqs", func(t *testing.T) { + const bypassSNSTrue = true + testIntegrationAthenaSearchEventsBySearchTerm(t, bypassSNSTrue) + }) +} + func testIntegrationAthenaEventPagination(t *testing.T, bypassSNS bool) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() @@ -172,6 +183,22 @@ func testIntegrationAthenaEventPagination(t *testing.T, bypassSNS bool) { eventsSuite.EventPagination(t) } +func testIntegrationAthenaSearchEventsBySearchTerm(t *testing.T, bypassSNS bool) { + ctx := t.Context() + ac := SetupAthenaContext(t, ctx, AthenaContextConfig{BypassSNS: bypassSNS}) + auditLogger := &EventuallyConsistentAuditLogger{ + Inner: ac.log, + // Additional 5s is used to compensate for uploading parquet on s3. + QueryDelay: ac.batcherInterval + 5*time.Second, + } + eventsSuite := test.EventsSuite{ + Log: auditLogger, + Clock: ac.clock, + } + + eventsSuite.SearchEventsBySearchTerm(t) +} + func TestIntegrationAthenaLargeEvents(t *testing.T) { t.Run("sns", func(t *testing.T) { const bypassSNSFalse = false diff --git a/lib/events/athena/querier.go b/lib/events/athena/querier.go index 37f47c45e96..41c08eef4b4 100644 --- a/lib/events/athena/querier.go +++ b/lib/events/athena/querier.go @@ -208,7 +208,7 @@ func (q *querier) SearchEvents(ctx context.Context, req events.SearchEventsReque limit: req.Limit, order: req.Order, startKey: startKeyset, - filter: searchEventsFilter{eventTypes: req.EventTypes}, + filter: searchEventsFilter{eventTypes: req.EventTypes, search: req.Search}, sessionID: "", }) return events, keyset, trace.Wrap(err) @@ -220,7 +220,7 @@ func (q *querier) SearchEvents(ctx context.Context, req events.SearchEventsReque limit: req.Limit, order: req.Order, startKey: startKeyset, - filter: searchEventsFilter{eventTypes: req.EventTypes}, + filter: searchEventsFilter{eventTypes: req.EventTypes, search: req.Search}, sessionID: "", }) return events, keyset, trace.Wrap(err) @@ -662,6 +662,7 @@ func (q *querier) searchEvents(ctx context.Context, req searchEventsRequest) ([] type searchEventsFilter struct { eventTypes []string + search string condition utils.FieldsCondition } @@ -673,7 +674,8 @@ type queryBuilder struct { // withTicks wraps string with ticks. // string params in athena need to be wrapped by "ticks". func withTicks(in string) string { - return fmt.Sprintf("'%s'", in) + escaped := strings.ReplaceAll(in, "'", "''") + return fmt.Sprintf("'%s'", escaped) } func sliceWithTicks(ss []string) []string { @@ -740,6 +742,12 @@ func prepareQuery(params searchParams) (query string, execParams []string, err e ) } + if params.filter.search != "" { + for term := range strings.FieldsSeq(strings.ToLower(params.filter.search)) { + qb.Append(" AND strpos(lower(event_data), ?) > 0", withTicks(term)) + } + } + if params.order == types.EventOrderAscending { if params.startKeyset != nil { qb.Append(` AND (event_time, uid) > (?,?)`, diff --git a/lib/events/athena/querier_test.go b/lib/events/athena/querier_test.go index 100815cb5d6..b519496f671 100644 --- a/lib/events/athena/querier_test.go +++ b/lib/events/athena/querier_test.go @@ -187,6 +187,57 @@ func TestSearchEvents(t *testing.T) { wantQueryParams(t, mock, append(timeRangeParams, "'app.create'", "'app.delete'")...) }, }, + { + name: "query with search terms", + searchParams: &events.SearchEventsRequest{ + From: fromUTC, + To: toUTC, + Limit: 100, + Search: "Root ALICE", + }, + queryResultsResps: singleCallResults(100), + check: func(t *testing.T, mock *mockAthenaExecutor, paginationKey string) { + t.Helper() + wantSingleCallToAthena(t, mock) + wantQuery(t, mock, selectFromPrefix+whereTimeRange+ + ` AND strpos(lower(event_data), ?) > 0 AND strpos(lower(event_data), ?) > 0 ORDER BY event_time ASC, uid ASC LIMIT 100;`) + wantQueryParams(t, mock, append(timeRangeParams, "'root'", "'alice'")...) + }, + }, + { + name: "query with apostrophe in search term", + searchParams: &events.SearchEventsRequest{ + From: fromUTC, + To: toUTC, + Limit: 100, + Search: "O'Connor", + }, + queryResultsResps: singleCallResults(100), + check: func(t *testing.T, mock *mockAthenaExecutor, paginationKey string) { + t.Helper() + wantSingleCallToAthena(t, mock) + wantQuery(t, mock, selectFromPrefix+whereTimeRange+ + ` AND strpos(lower(event_data), ?) > 0 ORDER BY event_time ASC, uid ASC LIMIT 100;`) + wantQueryParams(t, mock, append(timeRangeParams, "'o''connor'")...) + }, + }, + { + name: "query with wildcard characters in search term", + searchParams: &events.SearchEventsRequest{ + From: fromUTC, + To: toUTC, + Limit: 100, + Search: "alice_admin svc%prod", + }, + queryResultsResps: singleCallResults(100), + check: func(t *testing.T, mock *mockAthenaExecutor, paginationKey string) { + t.Helper() + wantSingleCallToAthena(t, mock) + wantQuery(t, mock, selectFromPrefix+whereTimeRange+ + ` AND strpos(lower(event_data), ?) > 0 AND strpos(lower(event_data), ?) > 0 ORDER BY event_time ASC, uid ASC LIMIT 100;`) + wantQueryParams(t, mock, append(timeRangeParams, "'alice_admin'", "'svc%prod'")...) + }, + }, { name: "session id", searchSessionParams: &events.SearchSessionEventsRequest{ diff --git a/lib/events/dynamoevents/dynamoevents.go b/lib/events/dynamoevents/dynamoevents.go index 2d3bca38679..c0d9d0f8c56 100644 --- a/lib/events/dynamoevents/dynamoevents.go +++ b/lib/events/dynamoevents/dynamoevents.go @@ -775,7 +775,7 @@ type legacyCheckpointKey struct { // // This function may never return more than 1 MiB of event data. func (l *Log) SearchEvents(ctx context.Context, req events.SearchEventsRequest) ([]apievents.AuditEvent, string, error) { - values, next, err := l.searchEventsWithFilter(ctx, req.From, req.To, apidefaults.Namespace, req.Limit, req.Order, req.StartKey, searchEventsFilter{eventTypes: req.EventTypes}, "") + values, next, err := l.searchEventsWithFilter(ctx, req.From, req.To, apidefaults.Namespace, req.Limit, req.Order, req.StartKey, searchEventsFilter{eventTypes: req.EventTypes, search: req.Search}, "") if err != nil { return nil, "", trace.Wrap(err) } @@ -787,7 +787,7 @@ func (l *Log) SearchEvents(ctx context.Context, req events.SearchEventsRequest) } func (l *Log) SearchUnstructuredEvents(ctx context.Context, req events.SearchEventsRequest) ([]*auditlogpb.EventUnstructured, string, error) { - values, next, err := l.searchEventsWithFilter(ctx, req.From, req.To, apidefaults.Namespace, req.Limit, req.Order, req.StartKey, searchEventsFilter{eventTypes: req.EventTypes}, "") + values, next, err := l.searchEventsWithFilter(ctx, req.From, req.To, apidefaults.Namespace, req.Limit, req.Order, req.StartKey, searchEventsFilter{eventTypes: req.EventTypes, search: req.Search}, "") if err != nil { return nil, "", trace.Wrap(err) } @@ -1109,6 +1109,7 @@ func (l *Log) SearchSessionEvents(ctx context.Context, req events.SearchSessionE type searchEventsFilter struct { eventTypes []string + search string condExpr string condParams condFilterParams filterFunc utils.FieldsCondition @@ -1618,6 +1619,9 @@ func (l *eventsFetcher) processQueryOutput(output *dynamodb.QueryOutput) ([]even if err != nil { return nil, false, trace.Wrap(err) } + if l.filter.search != "" && !events.MatchSearch(l.filter.search, string(data)) { + continue + } // Stop early when the fetcher's total size exceeds the response size limit. if l.totalSize+len(data) > events.MaxEventBytesInResponse { diff --git a/lib/events/dynamoevents/dynamoevents_test.go b/lib/events/dynamoevents/dynamoevents_test.go index 6eec968abc1..7f047d2cfff 100644 --- a/lib/events/dynamoevents/dynamoevents_test.go +++ b/lib/events/dynamoevents/dynamoevents_test.go @@ -130,6 +130,12 @@ func TestSearchSessionEvensBySessionID(t *testing.T) { tt.suite.SearchSessionEventsBySessionID(t) } +func TestSearchEventsBySearchTerm(t *testing.T) { + tt := setupDynamoContext(t) + + tt.suite.SearchEventsBySearchTerm(t) +} + // TestCheckpointOutsideOfWindow tests if [Log] doesn't panic // if checkpoint date is outside of the window [fromUTC,toUTC]. func TestCheckpointOutsideOfWindow(t *testing.T) { diff --git a/lib/events/filelog.go b/lib/events/filelog.go index 45070fbe8d0..5fcd1157b41 100644 --- a/lib/events/filelog.go +++ b/lib/events/filelog.go @@ -716,18 +716,7 @@ func (l *FileLog) findInFile(path string, filter searchEventsFilter) ([]EventFie } // 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 + accepted = MatchSearch(filter.search, string(scanner.Bytes())) } if accepted { @@ -782,6 +771,24 @@ func (f ByTimeAndIndex) Swap(i, j int) { f[i], f[j] = f[j], f[i] } +// MatchSearch returns true if all whitespace-delimited search terms are present +// in text, using case-insensitive substring matching. +func MatchSearch(search, text string) bool { + terms := strings.Fields(strings.ToLower(search)) + if len(terms) == 0 { + return true + } + + text = strings.ToLower(text) + for _, term := range terms { + if !strings.Contains(text, term) { + return false + } + } + + return true +} + // getTime converts json time to string func getTime(v any) time.Time { sval, ok := v.(string) diff --git a/lib/events/filelog_test.go b/lib/events/filelog_test.go index 5d793117a3c..171504d1d5c 100644 --- a/lib/events/filelog_test.go +++ b/lib/events/filelog_test.go @@ -274,6 +274,58 @@ func TestFileLogCheckpoint(t *testing.T) { } } +func TestMatchSearch(t *testing.T) { + tests := []struct { + name string + search string + text string + want bool + }{ + { + name: "empty search matches any text", + search: "", + text: `{"user":"alice"}`, + want: true, + }, + { + name: "whitespace-only search matches any text", + search: " \t\n ", + text: `{"user":"alice"}`, + want: true, + }, + { + name: "single term is case-insensitive", + search: "ALICE", + text: `{"user":"alice-search-target"}`, + want: true, + }, + { + name: "all search terms must be present", + search: "target alice", + text: `{"user":"alice-search-target"}`, + want: true, + }, + { + name: "missing term does not match", + search: "target carol", + text: `{"user":"alice-search-target"}`, + want: false, + }, + { + name: "wildcard-like characters are treated literally", + search: "svc%prod", + text: `route svc%prod backend`, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, MatchSearch(tt.search, tt.text)) + }) + } +} + func TestSearchSessionEvents(t *testing.T) { clock := clockwork.NewFakeClock() start := clock.Now() diff --git a/lib/events/firestoreevents/firestoreevents.go b/lib/events/firestoreevents/firestoreevents.go index 9e1e97a0aef..4e630f656d2 100644 --- a/lib/events/firestoreevents/firestoreevents.go +++ b/lib/events/firestoreevents/firestoreevents.go @@ -369,7 +369,7 @@ func (l *Log) SearchEvents(ctx context.Context, req events.SearchEventsRequest) limit: req.Limit, order: req.Order, lastKey: req.StartKey, - filter: searchEventsFilter{eventTypes: req.EventTypes}, + filter: searchEventsFilter{eventTypes: req.EventTypes, search: req.Search}, sessionID: "", }) if err != nil { @@ -479,6 +479,20 @@ func (l *Log) query( docID = checkpointParts[1] } + getCheckpointTime := func(docSnap *firestore.DocumentSnapshot) (int64, error) { + createdAtValue, ok := docSnap.Data()[createdAtDocProperty] + if !ok { + return 0, trace.BadParameter("missing %q field in event document %q", createdAtDocProperty, docSnap.Ref.ID) + } + + checkpointTime, ok := createdAtValue.(int64) + if !ok { + return 0, trace.BadParameter("invalid %q type %T in event document %q", createdAtDocProperty, createdAtValue, docSnap.Ref.ID) + } + + return checkpointTime, nil + } + for { if lastKey != "" { query = query.StartAfter(checkpointTime, docID) @@ -520,13 +534,26 @@ func (l *Log) query( return nil, "", trace.Errorf("failed to unmarshal event %v", err) } + if filter.search != "" && !events.MatchSearch(filter.search, string(data)) { + checkpointTime, err = getCheckpointTime(docSnap) + if err != nil { + return nil, "", trace.Wrap(err) + } + docID = docSnap.Ref.ID + lastKey = strconv.FormatInt(checkpointTime, 10) + ":" + docID + continue + } + // if the total size of the events exceeds the limit, return the events // collected so far and the last key to resume the query. if totalSize+len(data) >= events.MaxEventBytesInResponse { return values, lastKey, nil } - checkpointTime = docSnap.Data()[createdAtDocProperty].(int64) + checkpointTime, err = getCheckpointTime(docSnap) + if err != nil { + return nil, "", trace.Wrap(err) + } docID = docSnap.Ref.ID lastKey = strconv.FormatInt(checkpointTime, 10) + ":" + docID @@ -588,6 +615,7 @@ func (l *Log) GetEventExportChunks(ctx context.Context, req *auditlogpb.GetEvent type searchEventsFilter struct { eventTypes []string + search string condition utils.FieldsCondition } @@ -663,7 +691,7 @@ func (l *Log) SearchUnstructuredEvents(ctx context.Context, req events.SearchEve limit: req.Limit, order: req.Order, lastKey: req.StartKey, - filter: searchEventsFilter{eventTypes: req.EventTypes}, + filter: searchEventsFilter{eventTypes: req.EventTypes, search: req.Search}, sessionID: "", }) diff --git a/lib/events/firestoreevents/firestoreevents_test.go b/lib/events/firestoreevents/firestoreevents_test.go index afcadfefed7..2c50b8f6d7a 100644 --- a/lib/events/firestoreevents/firestoreevents_test.go +++ b/lib/events/firestoreevents/firestoreevents_test.go @@ -129,12 +129,18 @@ func (tt *firestoreContext) testSearchSessionEvensBySessionID(t *testing.T) { tt.suite.SearchSessionEventsBySessionID(t) } +func (tt *firestoreContext) testSearchEventsBySearchTerm(t *testing.T) { + tt.setupTest(t) + tt.suite.SearchEventsBySearchTerm(t) +} + func TestFirestoreEvents(t *testing.T) { tt := setupFirestoreContext(t) t.Run("TestSessionEventsCRUD", tt.testSessionEventsCRUD) t.Run("TestPagination", tt.testPagination) t.Run("TestSearchSessionEvensBySessionID", tt.testSearchSessionEvensBySessionID) + t.Run("TestSearchEventsBySearchTerm", tt.testSearchEventsBySearchTerm) } func emulatorRunning() bool { diff --git a/lib/events/pgevents/pgevents.go b/lib/events/pgevents/pgevents.go index 35e787a97d1..0f49e1286e1 100644 --- a/lib/events/pgevents/pgevents.go +++ b/lib/events/pgevents/pgevents.go @@ -428,7 +428,7 @@ func (l *Log) EmitAuditEvent(ctx context.Context, event apievents.AuditEvent) er func (l *Log) searchEvents( ctx context.Context, fromTime, toTime time.Time, - eventTypes []string, cond *utils.ToFieldsConditionConfig, sessionID string, + eventTypes []string, cond *utils.ToFieldsConditionConfig, sessionID, search string, limit int, order types.EventOrder, startKey string, ) ([]events.EventFields, string, error) { if limit <= 0 { @@ -457,6 +457,8 @@ func (l *Log) searchEvents( } } + searchTerms := strings.Fields(strings.ToLower(search)) + sessionUUID := l.deriveSessionID(ctx, sessionID) var qb strings.Builder @@ -473,6 +475,9 @@ func (l *Log) searchEvents( // no matter what the argument is qb.WriteString(" AND events.session_id != '00000000-0000-0000-0000-000000000000' AND events.session_id = @session_id") } + for i := range searchTerms { + fmt.Fprintf(&qb, " AND POSITION(@search_term_%d IN lower(events.event_data::text)) > 0", i) + } if order != types.EventOrderDescending { if startKey != "" { qb.WriteString(" AND (events.event_time, events.event_id) > (@start_time, @start_id)") @@ -494,6 +499,9 @@ func (l *Log) searchEvents( "start_time": startTime, "start_id": startID, } + for i, term := range searchTerms { + queryArgs[fmt.Sprintf("search_term_%d", i)] = term + } const fetchSize = defaults.EventsIterationLimit fetchQuery := fmt.Sprintf("FETCH %d FROM cur", fetchSize) @@ -588,7 +596,7 @@ func (l *Log) SearchEvents(ctx context.Context, req events.SearchEventsRequest) var emptyCond *utils.ToFieldsConditionConfig const emptySessionID = "" - evtsRaw, next, err := l.searchEvents(ctx, req.From, req.To, req.EventTypes, emptyCond, emptySessionID, req.Limit, req.Order, req.StartKey) + evtsRaw, next, err := l.searchEvents(ctx, req.From, req.To, req.EventTypes, emptyCond, emptySessionID, req.Search, req.Limit, req.Order, req.StartKey) if err != nil { return nil, next, trace.Wrap(err) } @@ -605,7 +613,7 @@ func (l *Log) SearchUnstructuredEvents(ctx context.Context, req events.SearchEve var emptyCond *utils.ToFieldsConditionConfig const emptySessionID = "" - evtsRaw, next, err := l.searchEvents(ctx, req.From, req.To, req.EventTypes, emptyCond, emptySessionID, req.Limit, req.Order, req.StartKey) + evtsRaw, next, err := l.searchEvents(ctx, req.From, req.To, req.EventTypes, emptyCond, emptySessionID, req.Search, req.Limit, req.Order, req.StartKey) if err != nil { return nil, next, trace.Wrap(err) } @@ -626,7 +634,8 @@ func (l *Log) GetEventExportChunks(ctx context.Context, req *auditlogpb.GetEvent // SearchSessionEvents implements [events.AuditLogger]. func (l *Log) SearchSessionEvents(ctx context.Context, req events.SearchSessionEventsRequest) ([]apievents.AuditEvent, string, error) { - evtsRaw, next, err := l.searchEvents(ctx, req.From, req.To, events.SessionRecordingEvents, req.Cond, req.SessionID, req.Limit, req.Order, req.StartKey) + const emptySearch = "" + evtsRaw, next, err := l.searchEvents(ctx, req.From, req.To, events.SessionRecordingEvents, req.Cond, req.SessionID, emptySearch, req.Limit, req.Order, req.StartKey) if err != nil { return nil, next, trace.Wrap(err) } diff --git a/lib/events/pgevents/pgevents_test.go b/lib/events/pgevents/pgevents_test.go index 0ba2190aeee..5ab36b55458 100644 --- a/lib/events/pgevents/pgevents_test.go +++ b/lib/events/pgevents/pgevents_test.go @@ -78,6 +78,10 @@ func TestPostgresEvents(t *testing.T) { truncateEvents(t) suite.SearchSessionEventsBySessionID(t) }) + t.Run("SearchEventsBySearchTerm", func(t *testing.T) { + truncateEvents(t) + suite.SearchEventsBySearchTerm(t) + }) } // TestLog_nonStandardSessionID tests for @@ -136,7 +140,8 @@ func TestLog_nonStandardSessionID(t *testing.T) { []string{appStartEvent.Metadata.Type}, // eventTypes nil, // cond appStartEvent.SessionID, - 2, // limit + "", // search + 2, // limit types.EventOrderAscending, "", // startKey ) diff --git a/lib/events/test/suite.go b/lib/events/test/suite.go index ac05ae60a57..e73ac5e4169 100644 --- a/lib/events/test/suite.go +++ b/lib/events/test/suite.go @@ -519,6 +519,57 @@ Outer: } } +func (s *EventsSuite) SearchEventsBySearchTerm(t *testing.T) { + ctx := t.Context() + baseTime := time.Now().UTC() + + testUsers := []string{"alice-search-target", "bob-search-target"} + for i, user := range testUsers { + err := s.Log.EmitAuditEvent(ctx, &apievents.UserLogin{ + Method: events.LoginMethodSAML, + Status: apievents.Status{Success: true}, + UserMetadata: apievents.UserMetadata{User: user}, + Metadata: apievents.Metadata{ + ID: uuid.NewString(), + Type: events.UserLoginEvent, + Time: baseTime.Add(time.Second * time.Duration(i)), + }, + }) + require.NoError(t, err) + } + + if s.QueryDelay != 0 { + time.Sleep(s.QueryDelay) + } + + searchAndAssertUsers := func(search string, wantUsers ...string) { + require.EventuallyWithT(t, func(t *assert.CollectT) { + history, _, err := s.Log.SearchEvents(ctx, events.SearchEventsRequest{ + From: baseTime.Add(-1 * time.Minute), + To: baseTime.Add(time.Hour), + Limit: 100, + Order: types.EventOrderAscending, + Search: search, + }) + require.NoError(t, err) + + gotUsers := make([]string, 0, len(history)) + for _, event := range history { + loginEvent, ok := event.(*apievents.UserLogin) + require.True(t, ok) + gotUsers = append(gotUsers, loginEvent.User) + } + + require.ElementsMatch(t, wantUsers, gotUsers) + }, 30*time.Second, 500*time.Millisecond) + } + + searchAndAssertUsers("alice-search-target", "alice-search-target") + searchAndAssertUsers("search-target alice", "alice-search-target") + searchAndAssertUsers("search-target", "alice-search-target", "bob-search-target") + searchAndAssertUsers("search-target carol") +} + // SessionEventsCRUD covers session events func (s *EventsSuite) SessionEventsCRUD(t *testing.T) { loginTime := s.Clock.Now().UTC() diff --git a/web/packages/teleport/src/Audit/Audit.test.tsx b/web/packages/teleport/src/Audit/Audit.test.tsx index 05b80269e46..2ed5ff46dee 100644 --- a/web/packages/teleport/src/Audit/Audit.test.tsx +++ b/web/packages/teleport/src/Audit/Audit.test.tsx @@ -31,7 +31,8 @@ import TeleportContext from 'teleport/teleportContext'; import { renderWithMemoryRouter } from 'teleport/test/helpers/router'; import { ContextProvider } from '..'; -import { AuditContainer } from './Audit'; +import { Audit, AuditContainer } from './Audit'; +import type { State } from './useAuditEvents'; const mio = mockIntersectionObserver(); @@ -93,6 +94,54 @@ describe('Audit', () => { expect(router.state.location.pathname).toBe('/web/cluster/root/audit'); expect(router.state.location.search).toContain('order=ASC'); }); + + it('does not fetch next page while placeholder data is shown', async () => { + const ctx = createTeleportContext(); + jest + .spyOn(ctx.clusterService, 'fetchClusters') + .mockImplementation(() => new Promise(() => {})); + + const fetchNextPage = jest.fn(); + + renderWithMemoryRouter( + , + { + path: cfg.routes.audit, + initialEntries: ['/web/cluster/root/audit'], + wrapper: makeWrapper({ ctx }), + } + ); + + act(mio.enterAll); + + expect(fetchNextPage).not.toHaveBeenCalled(); + }); }); function renderComponent(ctx: TeleportContext) { @@ -116,3 +165,30 @@ function makeWrapper({ ctx }: { ctx: TeleportContext }) { ); }; } + +function makeState( + ctx: TeleportContext, + overrides: Partial = {} +): State { + return { + events: [], + fetchNextPage: jest.fn(), + hasNextPage: false, + isPlaceholderData: false, + isFetchingNextPage: false, + isLoading: false, + error: null, + isSuccess: true, + refetch: jest.fn(), + isError: false, + clusterId: 'root', + range: undefined, + setRange: jest.fn(), + search: '', + setSearch: jest.fn(), + sort: { fieldName: 'time', dir: 'DESC' }, + setSort: jest.fn(), + ctx, + ...overrides, + }; +} diff --git a/web/packages/teleport/src/Audit/Audit.tsx b/web/packages/teleport/src/Audit/Audit.tsx index e03dbb78972..1b873f0146e 100644 --- a/web/packages/teleport/src/Audit/Audit.tsx +++ b/web/packages/teleport/src/Audit/Audit.tsx @@ -54,6 +54,7 @@ export function Audit(props: State) { clusterId, fetchNextPage, hasNextPage, + isPlaceholderData, isFetchingNextPage, error, isLoading, @@ -68,9 +69,12 @@ export function Audit(props: State) { const [errorMessage, setErrorMessage] = useState(''); + const canFetchNextPage = + hasNextPage && !isFetchingNextPage && !isError && !isPlaceholderData; + const { setTrigger } = useInfiniteScroll({ fetch: async () => { - if (hasNextPage && !isFetchingNextPage && !isError) { + if (canFetchNextPage) { fetchNextPage(); } }, @@ -81,11 +85,16 @@ export function Audit(props: State) { }; const onLoadMoreClicked = () => { - if (hasNextPage && !isFetchingNextPage) { + if (canFetchNextPage) { fetchNextPage(); } }; + const showSkeleton = + (isLoading && events.length === 0) || + isFetchingNextPage || + isPlaceholderData; + return ( @@ -121,7 +130,7 @@ export function Audit(props: State) { hideAdvancedSearch={true} filter={{ search }} /> - {!isLoading && ( + {!isLoading && !isPlaceholderData && ( )} - {((isLoading && events.length === 0) || isFetchingNextPage) && ( - - )} -
+ {showSkeleton && } + {!isPlaceholderData &&
} {isError && events.length > 0 && !isLoading && ( diff --git a/web/packages/teleport/src/Audit/EventList/EventList.tsx b/web/packages/teleport/src/Audit/EventList/EventList.tsx index c744f3e96e1..fe55010412c 100644 --- a/web/packages/teleport/src/Audit/EventList/EventList.tsx +++ b/web/packages/teleport/src/Audit/EventList/EventList.tsx @@ -18,7 +18,7 @@ import { useState } from 'react'; -import { ButtonBorder, Flex } from 'design'; +import { ButtonBorder, Flex, Text } from 'design'; import Table, { Cell } from 'design/DataTable'; import { Event } from 'teleport/services/audit'; @@ -38,7 +38,7 @@ export default function EventList(props: Props) { columns={[ { key: 'codeDesc', - headerText: 'Type', + headerText: 'Event', isSortable: false, render: event => renderTypeCell(event), }, @@ -83,7 +83,7 @@ export const renderActionCell = ( onShowDetails(event)} width="87px" > @@ -94,11 +94,17 @@ export const renderActionCell = ( ); export const renderTimeCell = ({ time }: Event) => ( - {time.toISOString()} + + {time.toISOString()} + ); export function renderDescCell({ message }: Event) { - return {message}; + return ( + + {message} + + ); } type Props = { diff --git a/web/packages/teleport/src/Audit/EventList/EventTypeCell.tsx b/web/packages/teleport/src/Audit/EventList/EventTypeCell.tsx index 7909a9dfde5..823b631cabc 100644 --- a/web/packages/teleport/src/Audit/EventList/EventTypeCell.tsx +++ b/web/packages/teleport/src/Audit/EventList/EventTypeCell.tsx @@ -19,7 +19,9 @@ import styled from 'styled-components'; import { Cell } from 'design/DataTable'; +import Flex from 'design/Flex'; import * as Icons from 'design/Icon'; +import Text from 'design/Text/Text'; import { Event, EventCode, eventCodes } from 'teleport/services/audit'; @@ -372,7 +374,7 @@ export default function renderTypeCell(event: Event) { const Icon = EventIconMap[event.code] || Icons.ListThin; const iconProps = { - p: 1, + p: 2, mr: 3, }; @@ -380,7 +382,19 @@ export default function renderTypeCell(event: Event) { - {event.codeDesc} + + + {event.raw.event} + + + {event.codeDesc} + + ); diff --git a/web/packages/teleport/src/Audit/useAuditEvents.ts b/web/packages/teleport/src/Audit/useAuditEvents.ts index c43b349379c..d3680430f03 100644 --- a/web/packages/teleport/src/Audit/useAuditEvents.ts +++ b/web/packages/teleport/src/Audit/useAuditEvents.ts @@ -55,6 +55,7 @@ export default function useAuditEvents( error, fetchNextPage, hasNextPage, + isPlaceholderData, isFetchingNextPage, isLoading, isSuccess, @@ -152,6 +153,7 @@ export default function useAuditEvents( events, fetchNextPage, hasNextPage, + isPlaceholderData, isFetchingNextPage, isLoading, error,