Update Audit page to use server side search/pagination (#60480)

This PR will enable the audit page to be fully server side searchable
and paginated. I removed the sort by "type" because we don't have that
sort of searching in the backend, but you can still search/filter by it,
so you don't lose much functionality. We decided to remove the "range"
options (we = ux team) of Today, Last 7 Days, an Custom Range, and just
have an empty range, or one selected. with infinite scroll it doesnt
make much sense for it to scroll down for just "today". this applies to
the other spots it was used as well.

the biggest line change here is due to the proto change

Fixes #33672
This commit is contained in:
Michael
2025-11-03 17:24:29 -06:00
committed by GitHub
parent d37884ee8e
commit eb99d82a04
27 changed files with 1669 additions and 1174 deletions
+2 -1
View File
@@ -2801,7 +2801,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) ([]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, search string) ([]events.AuditEvent, string, error) {
request := &proto.GetEventsRequest{
Namespace: namespace,
StartDate: fromUTC,
@@ -2810,6 +2810,7 @@ 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)
File diff suppressed because it is too large Load Diff
@@ -1590,6 +1590,8 @@ message GetEventsRequest {
// Order specifies an ascending or descending order of events.
// A value of 0 means a descending order and a value of 1 means an ascending order.
Order Order = 7;
// Search is an optional search term to filter events by (case-insensitive substring match).
string Search = 8;
}
message GetSessionEventsRequest {
+1 -1
Submodule e updated: 6560ca9ac4...d6df7f9455
+1 -1
View File
@@ -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) ([]events.AuditEvent, string, error)
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)
// 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).
@@ -60,7 +60,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) ([]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, search string) ([]events.AuditEvent, string, error) {
c.mu.Lock()
defer c.mu.Unlock()
@@ -106,7 +106,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)
}
@@ -109,6 +109,7 @@ func (api *Client) SearchAccessRequestEvents(ctx context.Context, reqID string)
100,
types.EventOrderAscending,
"",
"",
)
result := make([]*events.AccessRequestCreate, 0, len(auditEvents))
for _, event := range auditEvents {
+1 -1
View File
@@ -343,7 +343,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)
events, lastKey, err := c.APIClient.SearchEvents(ctx, req.From, req.To, apidefaults.Namespace, req.EventTypes, req.Limit, req.Order, req.StartKey, req.Search)
if err != nil {
return nil, "", trace.Wrap(err)
}
+1
View File
@@ -3913,6 +3913,7 @@ 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)
+2
View File
@@ -1188,6 +1188,8 @@ 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 {
+21 -2
View File
@@ -197,8 +197,11 @@ 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)
values, next, err := l.searchEventsWithFilter(ctx, req.From, req.To, req.Limit, req.Order, req.StartKey, searchEventsFilter{eventTypes: req.EventTypes})
l.logger.DebugContext(ctx, "SearchEvents", "from", req.From, "to", req.To, "event_type", req.EventTypes, "limit", req.Limit, "search", req.Search)
values, next, err := l.searchEventsWithFilter(ctx, req.From, req.To, req.Limit, req.Order, req.StartKey, searchEventsFilter{
eventTypes: req.EventTypes,
search: req.Search,
})
if err != nil {
return nil, "", trace.Wrap(err)
}
@@ -511,6 +514,7 @@ 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
@@ -710,6 +714,21 @@ 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)
+52 -2
View File
@@ -872,7 +872,16 @@ func (h *Handler) bindDefaultEndpoints() {
h.GET("/webapi/sites/:site/db/exec/ws", h.WithClusterAuthWebSocket(h.dbConnect))
// Audit events handlers.
h.GET("/webapi/sites/:site/events/search", h.WithClusterAuth(h.clusterSearchEvents)) // search site events
// 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/sessions", h.WithClusterAuth(h.clusterSearchSessionEvents)) // search site session events
h.GET("/webapi/sites/:site/ttyplayback/:sid", h.WithClusterAuth(h.ttyPlaybackHandle))
@@ -4370,6 +4379,47 @@ 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
@@ -4528,7 +4578,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 := query.Get(name)
value := strings.ToLower(query.Get(name))
switch value {
case "desc":
return types.EventOrderDescending, nil
@@ -67,9 +67,10 @@ export const Failed = () => {
export const AllPossibleEvents = () => (
<EventList
events={events}
fetchMore={() => null}
fetchStatus={''}
pageSize={1000}
search=""
setSearch={() => null}
setSort={() => null}
sort={{ dir: 'ASC', fieldName: 'created' }}
/>
);
@@ -0,0 +1,148 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
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(<AuditContainer />, {
wrapper: makeWrapper({ history, ctx }),
}),
user,
history,
};
}
function makeWrapper({
history,
ctx,
}: {
history: ReturnType<typeof createMemoryHistory>;
ctx: TeleportContext;
}) {
return ({ children }: PropsWithChildren) => {
return (
<MemoryRouter>
<QueryClientProvider client={testQueryClient}>
<ContextProvider ctx={ctx}>
<ConfiguredThemeProvider theme={darkTheme}>
<Router history={history}>
<Route path={cfg.routes.audit}>{children}</Route>
</Router>
</ConfiguredThemeProvider>
</ContextProvider>
</QueryClientProvider>
</MemoryRouter>
);
};
}
+85 -21
View File
@@ -16,10 +16,12 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useState } from 'react';
import { useState, type PropsWithChildren } from 'react';
import styled from 'styled-components';
import { Box, Indicator } from 'design';
import { Box, ButtonSecondary, Flex } from 'design';
import { Danger } from 'design/Alert';
import { useInfiniteScroll } from 'shared/hooks';
import { ExternalAuditStorageCta } from '@gravitational/teleport/src/components/ExternalAuditStorageCta';
import { ClusterDropdown } from 'teleport/components/ClusterDropdown/ClusterDropdown';
@@ -33,6 +35,7 @@ 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() {
@@ -44,31 +47,63 @@ export function AuditContainer() {
export function Audit(props: State) {
const {
attempt,
range,
setRange,
rangeOptions,
events,
clusterId,
fetchMore,
fetchStatus,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
error,
isLoading,
search,
setSearch,
sort,
setSort,
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 (
<FeatureBox>
<FeatureBox unsetHeight>
<FeatureHeader alignItems="center">
<FeatureHeaderTitle mr="8">Audit Log</FeatureHeaderTitle>
<RangePicker
ml="auto"
range={range}
ranges={rangeOptions}
onChangeRange={setRange}
/>
<RangePicker ml="auto" range={range} onChangeRange={setRange} />
</FeatureHeader>
<ExternalAuditStorageCta />
{attempt.status === 'failed' && <Danger> {attempt.statusText} </Danger>}
{!isLoading && isError && error && (
<ErrorsContainer>
<DangerWithBackground
primaryAction={{
content: 'Retry',
onClick: onRetryClicked,
}}
>
{error.message}
</DangerWithBackground>
</ErrorsContainer>
)}
{!errorMessage && (
<ClusterDropdown
clusterLoader={ctx.clusterService}
@@ -78,18 +113,47 @@ export function Audit(props: State) {
/>
)}
{errorMessage && <Danger>{errorMessage}</Danger>}
{attempt.status === 'processing' && (
<Box textAlign="center" m={10}>
<Indicator />
{isLoading && events.length === 0 && (
<Box mt={2}>
<EventListSkeleton />
</Box>
)}
{attempt.status === 'success' && (
<Box mt={2}>
<EventList
events={events}
fetchMore={fetchMore}
fetchStatus={fetchStatus}
search={search}
setSearch={setSearch}
sort={sort}
setSort={setSort}
/>
)}
{isFetchingNextPage && <EventListSkeleton />}
<div ref={setTrigger} />
{isError && events.length > 0 && !isLoading && (
<Box mt={2} textAlign="center">
<ButtonSecondary onClick={onLoadMoreClicked}>
Load more
</ButtonSecondary>
</Box>
)}
</Box>
</FeatureBox>
);
}
function ErrorsContainer(props: PropsWithChildren<unknown>) {
return <ErrorBox>{props.children}</ErrorBox>;
}
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};
`;
@@ -20,7 +20,7 @@ import { useState } from 'react';
import { ButtonBorder, Flex } from 'design';
import Table, { Cell } from 'design/DataTable';
import { dateTimeMatcher } from 'design/utils/match';
import { SearchPanel } from 'shared/components/Search';
import { Event } from 'teleport/services/audit';
@@ -30,17 +30,23 @@ import renderTypeCell from './EventTypeCell';
import { ViewInPolicyButton } from './ViewInPolicyButton';
export default function EventList(props: Props) {
const { events = [], fetchMore, fetchStatus, pageSize = 50 } = props;
const { events = [], search, setSearch, sort, setSort } = props;
const [detailsToShow, setDetailsToShow] = useState<Event>();
return (
<>
<SearchPanel
updateSearch={setSearch}
updateQuery={null}
hideAdvancedSearch={true}
filter={{ search }}
/>
<Table
data={events}
columns={[
{
key: 'codeDesc',
headerText: 'Type',
isSortable: true,
isSortable: false,
render: event => renderTypeCell(event),
},
{
@@ -60,14 +66,10 @@ export default function EventList(props: Props) {
},
]}
emptyText={'No Events Found'}
isSearchable
searchableProps={['code', 'codeDesc', 'time', 'user', 'message', 'id']}
customSearchMatchers={[dateTimeMatcher(['time'])]}
initialSort={{ key: 'time', dir: 'DESC' }}
pagination={{ pageSize }}
fetching={{
onFetchMore: fetchMore,
fetchStatus,
customSort={{
fieldName: sort.fieldName,
dir: sort.dir,
onSort: setSort,
}}
/>
{detailsToShow && (
@@ -108,7 +110,8 @@ export function renderDescCell({ message }: Event) {
type Props = {
events: State['events'];
fetchMore: State['fetchMore'];
fetchStatus: State['fetchStatus'];
pageSize?: number;
search: State['search'];
setSearch: State['setSearch'];
sort: State['sort'];
setSort: State['setSort'];
};
@@ -0,0 +1,83 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
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 <LoadingSkeleton count={18} Element={<LoadingEventRow />} />;
}
function LoadingEventRow() {
const [randomizedSize] = useState(() => ({
type: randomNum(60, 40),
description: randomNum(80, 50),
time: randomNum(100, 80),
}));
return (
<LoadingRow alignItems="center" height="46px" px={3}>
{/* Type column */}
<Flex flex="0 0 120px" pr={3}>
<ShimmerBox
height="12px"
css={`
width: ${randomizedSize.type}%;
`}
/>
</Flex>
{/* Description column */}
<Flex flex="1" pr={3}>
<ShimmerBox
height="12px"
css={`
width: ${randomizedSize.description}%;
`}
/>
</Flex>
{/* Created time column */}
<Flex flex="0 0 180px" pr={3}>
<ShimmerBox
height="12px"
css={`
width: ${randomizedSize.time}%;
`}
/>
</Flex>
{/* Action buttons column */}
<Flex flex="0 0 120px" justifyContent="flex-end">
<ShimmerBox height="12px" width="87px" />
</Flex>
</LoadingRow>
);
}
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]};
`;
+137 -73
View File
@@ -16,97 +16,161 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useEffect, useMemo, useState } from 'react';
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 useAttempt from 'shared/hooks/useAttemptNext';
import type { SortDir, SortType } from 'design/DataTable/types';
import {
EventRange,
getRangeOptions,
} from 'teleport/components/EventRangePicker';
import { Event, EventCode, formatters } from 'teleport/services/audit';
import { EventRange } from 'teleport/components/EventRangePicker';
import { 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 rangeOptions = useMemo(() => getRangeOptions(), []);
const [range, setRange] = useState<EventRange>(rangeOptions[0]);
const { attempt, setAttempt, run } = useAttempt('processing');
const [results, setResults] = useState<EventResult>({
events: [],
fetchStartKey: '',
fetchStatus: '',
});
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 : '';
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 });
});
}
// fetch gets events from beginning of range and
// replaces existing events list.
function fetch() {
run(() =>
ctx.auditService
.fetchEvents(clusterId, {
...range,
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,
})
.then(res =>
setResults({
events: res.events,
fetchStartKey: res.startKey,
fetchStatus: res.startKey ? '' : 'disabled',
})
)
);
}
startKey: pageParam,
limit: PAGE_SIZE,
search,
order: sortDir,
},
signal
),
initialPageParam: '',
getNextPageParam: lastPage => lastPage.startKey || undefined,
placeholderData: keepPreviousData,
staleTime: 30_000,
});
// 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(),
});
},
[history, location]
);
const sort: SortType = { fieldName: 'time', dir: sortDir };
return {
...results,
fetchMore,
events,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
error,
isSuccess,
refetch,
isError,
clusterId,
attempt,
range,
range:
fromParam && toParam
? {
from: new Date(fromParam),
to: new Date(toParam),
isCustom: true,
}
: undefined,
setRange,
rangeOptions,
search,
setSearch,
sort,
setSort,
ctx,
};
}
type EventResult = {
events: Event[];
fetchStatus: 'loading' | 'disabled' | '';
fetchStartKey: string;
};
export type State = ReturnType<typeof useAuditEvents>;
@@ -46,7 +46,6 @@ export function Recordings({
fetchMore,
range,
setRange,
rangeOptions,
attempt,
clusterId,
ctx,
@@ -56,12 +55,7 @@ export function Recordings({
<FeatureBox>
<FeatureHeader alignItems="center">
<FeatureHeaderTitle mr="8">Session Recordings</FeatureHeaderTitle>
<RangePicker
ml="auto"
range={range}
ranges={rangeOptions}
onChangeRange={setRange}
/>
<RangePicker ml="auto" range={range} onChangeRange={setRange} />
</FeatureHeader>
<ExternalAuditStorageCta />
{!errorMessage && (
@@ -125,7 +125,6 @@ export function ListSessionRecordings({
<RangePicker
ml="auto"
range={state.range}
ranges={ranges}
onChangeRange={handleSetRange}
/>
</Flex>
@@ -29,7 +29,5 @@ export const Picker = () => {
const rangeOptions = getRangeOptions();
const [range, setRange] = useState<EventRange>(rangeOptions[0]);
return (
<RangePicker range={range} ranges={rangeOptions} onChangeRange={setRange} />
);
return <RangePicker range={range} onChangeRange={setRange} />;
};
@@ -17,44 +17,28 @@
*/
import { useState } from 'react';
import { components, ValueContainerProps } from 'react-select';
import 'react-day-picker/dist/style.css';
import styled from 'styled-components';
import { Box, Text } from 'design';
import { Box, ButtonBorder, Text } from 'design';
import { displayDate } from 'design/datetime';
import Dialog from 'design/DialogConfirmation';
import Select, { Option } from 'shared/components/Select';
import { Calendar } from 'design/Icon';
import { useRefClickOutside } from 'shared/hooks/useRefClickOutside';
import { State } from 'teleport/Audit/useAuditEvents';
import { CustomRange } from './Custom';
import { EventRange } from './utils';
type RangeOption = Option<EventRange, string>;
export default function DataRange({ ml, range, onChangeRange, ranges }: Props) {
export default function DateRange({ ml, range, onChangeRange }: Props) {
const [isPickerOpen, openDayPicker] = useState(false);
const [rangeOptions] = useState(() =>
ranges.map(range => ({ value: range, label: range.name }))
);
const dayPickerRef = useRefClickOutside<HTMLDivElement>({
open: isPickerOpen,
setOpen: openDayPicker,
});
function handleOnChange(option: Option<EventRange>) {
if (option.value.isCustom) {
openDayPicker(true);
} else {
onChangeRange(option.value);
}
}
function onClosePicker() {
openDayPicker(false);
}
@@ -66,14 +50,15 @@ export default function DataRange({ ml, range, onChangeRange, ranges }: Props) {
return (
<>
<Box ml={ml} width="210px">
<Select
isSearchable={false}
components={{ ValueContainer }}
options={rangeOptions}
onChange={handleOnChange}
value={{ value: range, label: range.name }}
/>
<Box ml={ml}>
<DateRangeButton onClick={() => openDayPicker(true)}>
<Calendar size={16} mr={2} />
{range ? (
<Text>{`${displayDate(range.from)} - ${displayDate(range.to)}`}</Text>
) : (
<Text color="text.muted">Select date range...</Text>
)}
</DateRangeButton>
</Box>
<Dialog
dialogCss={() => ({ padding: '0' })}
@@ -82,7 +67,11 @@ export default function DataRange({ ml, range, onChangeRange, ranges }: Props) {
open={isPickerOpen}
>
<CustomRange
initialRange={{ from: range.from, to: range.to }}
initialRange={
range
? { from: range.from, to: range.to }
: { from: new Date(), to: new Date() }
}
onChange={onSetCustomRange}
ref={dayPickerRef}
/>
@@ -91,36 +80,16 @@ export default function DataRange({ ml, range, onChangeRange, ranges }: Props) {
);
}
const ValueContainer = ({
children,
...props
}: ValueContainerProps<RangeOption>) => {
const { isCustom, from, to } = props.getValue()[0].value;
if (isCustom) {
return (
<components.ValueContainer {...props}>
<ValueText color="text.main">
{`${displayDate(from)} - ${displayDate(to)}`}
</ValueText>
{children}
</components.ValueContainer>
);
}
return (
<components.ValueContainer {...props}>{children}</components.ValueContainer>
);
};
/** Positions the value text on the internal react-select grid. */
const ValueText = styled(Text)`
grid-area: 1/1/2/3;
const DateRangeButton = styled(ButtonBorder)`
display: flex;
align-items: center;
justify-content: flex-start;
min-width: 240px;
padding: 8px 12px;
`;
type Props = {
ml?: string | number;
range: State['range'];
onChangeRange: State['setRange'];
ranges: State['rangeOptions'];
range: EventRange | undefined;
onChangeRange: (range: EventRange) => void;
};
@@ -48,9 +48,12 @@ const FeatureHeaderTitle = styled(H1)`
/**
* Feature Box (container)
*/
const FeatureBox = styled(Flex)<{ hideBottomSpacing?: boolean }>`
const FeatureBox = styled(Flex)<{
hideBottomSpacing?: boolean;
unsetHeight?: boolean;
}>`
width: 100%;
height: 100%;
${props => !props.unsetHeight && 'height: 100%;'}
flex-direction: column;
padding-left: ${props => props.theme.space[6]}px;
padding-right: ${props => props.theme.space[6]}px;
+5 -1
View File
@@ -258,7 +258,9 @@ const cfg = {
clustersPath: '/v1/webapi/sites',
clusterInfoPath: '/v1/webapi/sites/:clusterId/info',
clusterAlertsPath: '/v1/webapi/sites/:clusterId/alerts',
// TODO (avatus): Delete in v21.0.0
clusterEventsPath: `/v1/webapi/sites/:clusterId/events/search?from=:start?&to=:end?&limit=:limit?&startKey=:startKey?&include=:include?`,
clusterEventsPathV2: `/v2/webapi/sites/:clusterId/events/search?from=:start?&to=:end?&limit=:limit?&startKey=:startKey?&include=:include?&search=:search?&order=:order?`,
clusterEventsRecordingsPath: `/v1/webapi/sites/:clusterId/events/search/sessions`,
connectionDiagnostic: `/v1/webapi/sites/:clusterId/diagnostics/connections`,
@@ -584,7 +586,7 @@ const cfg = {
},
getClusterEventsUrl(clusterId: string, params: UrlClusterEventsParams) {
return generatePath(cfg.api.clusterEventsPath, {
return generatePath(cfg.api.clusterEventsPathV2, {
clusterId,
...params,
});
@@ -1863,6 +1865,8 @@ export interface UrlClusterEventsParams {
limit?: number;
include?: string;
startKey?: string;
search?: string;
order?: string;
}
export interface UrlLauncherParams {
@@ -19,6 +19,7 @@
import api from 'teleport/services/api';
import AuditService from './audit';
import { EventQuery } from './types';
test('fetch events', async () => {
const audit = new AuditService();
@@ -88,9 +89,10 @@ test('fetch events', async () => {
expect(response.events[0].message).toBe('Unknown');
});
const params = {
const params: EventQuery = {
from: new Date(0),
to: new Date(0),
order: 'DESC',
};
const normalJson = {
@@ -19,12 +19,42 @@
import cfg from 'teleport/config';
import api from 'teleport/services/api';
import { isPathNotFoundError } from '../version/unsupported';
import makeEvent from './makeEvent';
import { EventQuery, EventResponse } from './types';
class AuditService {
maxFetchLimit = 5000;
async fetchEventsV2(
clusterId: string,
params: EventQuery,
abortSignal?: AbortSignal
): Promise<EventResponse> {
const limit = params.limit || this.maxFetchLimit;
const url = cfg.getClusterEventsUrl(clusterId, {
start: params.from && params.from.toISOString(),
end: params.to && params.to.toISOString(),
limit,
include: params.filterBy || undefined,
startKey: params.startKey || undefined,
search: params.search || undefined,
order: params.order || undefined,
});
try {
const json = await api.get(url, abortSignal);
const events = json.events || [];
return { events: events.map(makeEvent), startKey: json.startKey };
} catch (err) {
if (isPathNotFoundError(err)) {
return this.fetchEvents(clusterId, params);
}
throw err;
}
}
fetchEvents(clusterId: string, params: EventQuery): Promise<EventResponse> {
const start = params.from.toISOString();
const end = params.to.toISOString();
@@ -16,6 +16,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { SortDir } from '../agents';
// eventGroupTypes contains a map of events that were grouped under the same
// event type but have different event codes. This is used to filter out duplicate
// event types when listing event filters and provide modified description of event.
@@ -2377,6 +2379,8 @@ export type EventQuery = {
limit?: number;
startKey?: string;
filterBy?: string;
search?: string;
order: SortDir;
};
export type EventResponse = {