From 3979ea0c479a2c562d045584355600908e7e2d8b Mon Sep 17 00:00:00 2001 From: Eric Leijonmarck Date: Mon, 15 Jan 2024 12:13:38 +0000 Subject: [PATCH] Anonymous Access: Pagination for devices (#80028) * first commit * add: pagination to anondevices * fmt * swagger and tests * swagger * testing out test * fixing tests * made it possible to query for from and to time * refactor: change to query for ip adress instead * fix: tests --- .../src/selectors/pages.ts | 3 + .../anonymous/anonimpl/anonstore/database.go | 88 +++++++++++++++++ .../anonymous/anonimpl/anonstore/fake.go | 4 + pkg/services/anonymous/anonimpl/api/api.go | 54 +++++++++++ pkg/services/anonymous/anonimpl/impl.go | 10 ++ pkg/services/anonymous/anonimpl/impl_test.go | 83 ++++++++++++++++ pkg/services/anonymous/sortopts/sortopts.go | 97 +++++++++++++++++++ public/api-merged.json | 82 ++++++++++++++++ .../features/admin/UserListAnonymousPage.tsx | 89 ++++++++++++++++- .../features/admin/Users/AnonUsersTable.tsx | 38 +++++++- public/app/features/admin/state/actions.ts | 75 +++++++++++++- public/app/features/admin/state/reducers.ts | 53 +++++++++- public/app/types/user.ts | 9 ++ public/openapi3.json | 83 ++++++++++++++++ 14 files changed, 752 insertions(+), 16 deletions(-) create mode 100644 pkg/services/anonymous/sortopts/sortopts.go diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index e4064f6bdda..27371fa1522 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -329,6 +329,9 @@ export const Pages = { UsersListPage: { container: 'data-testid users-list-page', }, + UserAnonListPage: { + container: 'data-testid user-anon-list-page', + }, UsersListPublicDashboardsPage: { container: 'data-testid users-list-public-dashboards-page', DashboardsListModal: { diff --git a/pkg/services/anonymous/anonimpl/anonstore/database.go b/pkg/services/anonymous/anonimpl/anonstore/database.go index 8c5ed04db27..34470936217 100644 --- a/pkg/services/anonymous/anonimpl/anonstore/database.go +++ b/pkg/services/anonymous/anonimpl/anonstore/database.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/search/model" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" ) @@ -32,6 +33,30 @@ type Device struct { UpdatedAt time.Time `json:"updatedAt" xorm:"updated_at" db:"updated_at"` } +type DeviceSearchHitDTO struct { + DeviceID string `json:"deviceId" xorm:"device_id" db:"device_id"` + ClientIP string `json:"clientIp" xorm:"client_ip" db:"client_ip"` + UserAgent string `json:"userAgent" xorm:"user_agent" db:"user_agent"` + CreatedAt time.Time `json:"createdAt" xorm:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" xorm:"updated_at" db:"updated_at"` + LastSeenAt time.Time `json:"lastSeenAt"` +} + +type SearchDeviceQueryResult struct { + TotalCount int64 `json:"totalCount"` + Devices []*DeviceSearchHitDTO `json:"devices"` + Page int `json:"page"` + PerPage int `json:"perPage"` +} +type SearchDeviceQuery struct { + Query string + Page int + Limit int + From time.Time + To time.Time + SortOpts []model.SortOption +} + func (a *Device) CacheKey() string { return strings.Join([]string{cacheKeyPrefix, a.DeviceID}, ":") } @@ -47,6 +72,8 @@ type AnonStore interface { DeleteDevice(ctx context.Context, deviceID string) error // DeleteDevicesOlderThan deletes all devices that have no been updated since the given time. DeleteDevicesOlderThan(ctx context.Context, olderThan time.Time) error + // SearchDevices searches for devices within the 30 days active. + SearchDevices(ctx context.Context, query *SearchDeviceQuery) (*SearchDeviceQueryResult, error) } func ProvideAnonDBStore(sqlStore db.DB, deviceLimit int64) *AnonDBStore { @@ -183,3 +210,64 @@ func (s *AnonDBStore) DeleteDevicesOlderThan(ctx context.Context, olderThan time return err } + +func (s *AnonDBStore) SearchDevices(ctx context.Context, query *SearchDeviceQuery) (*SearchDeviceQueryResult, error) { + result := SearchDeviceQueryResult{ + Devices: make([]*DeviceSearchHitDTO, 0), + } + err := s.sqlStore.WithDbSession(ctx, func(dbSess *db.Session) error { + if query.From.IsZero() && !query.To.IsZero() { + return fmt.Errorf("from date must be set if to date is set") + } + if !query.From.IsZero() && query.To.IsZero() { + return fmt.Errorf("to date must be set if from date is set") + } + + // restricted only to last 30 days, if noting else specified + if query.From.IsZero() && query.To.IsZero() { + query.From = time.Now().Add(-anonymousDeviceExpiration) + query.To = time.Now() + } + + sess := dbSess.Table("anon_device").Alias("d") + + if query.Limit > 0 { + offset := query.Limit * (query.Page - 1) + sess.Limit(query.Limit, offset) + } + sess.Cols("d.id", "d.device_id", "d.client_ip", "d.user_agent", "d.updated_at") + + if len(query.SortOpts) > 0 { + for i := range query.SortOpts { + for j := range query.SortOpts[i].Filter { + sess.OrderBy(query.SortOpts[i].Filter[j].OrderBy()) + } + } + } else { + sess.Asc("d.user_agent") + } + + // add to query about from and to session + sess.Where("d.updated_at BETWEEN ? AND ?", query.From.UTC(), query.To.UTC()) + + if query.Query != "" { + queryWithWildcards := "%" + strings.Replace(query.Query, "\\", "", -1) + "%" + sess.Where("d.client_ip "+s.sqlStore.GetDialect().LikeStr()+" ?", queryWithWildcards) + } + + // get total + devices, err := s.ListDevices(ctx, &query.From, &query.To) + if err != nil { + return err + } + // cast to int64 + result.TotalCount = int64(len(devices)) + if err := sess.Find(&result.Devices); err != nil { + return err + } + result.Page = query.Page + result.PerPage = query.Limit + return nil + }) + return &result, err +} diff --git a/pkg/services/anonymous/anonimpl/anonstore/fake.go b/pkg/services/anonymous/anonimpl/anonstore/fake.go index 9c7249d37bf..8db978d309e 100644 --- a/pkg/services/anonymous/anonimpl/anonstore/fake.go +++ b/pkg/services/anonymous/anonimpl/anonstore/fake.go @@ -19,3 +19,7 @@ func (s *FakeAnonStore) CreateOrUpdateDevice(ctx context.Context, device *Device func (s *FakeAnonStore) CountDevices(ctx context.Context, from time.Time, to time.Time) (int64, error) { return 0, nil } + +func (s *FakeAnonStore) SearchDevices(ctx context.Context, query SearchDeviceQuery) (*SearchDeviceQueryResult, error) { + return nil, nil +} diff --git a/pkg/services/anonymous/anonimpl/api/api.go b/pkg/services/anonymous/anonimpl/api/api.go index defdf28d17a..d545f252deb 100644 --- a/pkg/services/anonymous/anonimpl/api/api.go +++ b/pkg/services/anonymous/anonimpl/api/api.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/anonymous/anonimpl/anonstore" + "github.com/grafana/grafana/pkg/services/anonymous/sortopts" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -50,6 +51,7 @@ func (api *AnonDeviceServiceAPI) RegisterAPIEndpoints() { auth := accesscontrol.Middleware(api.accesscontrol) api.RouterRegister.Group("/api/anonymous", func(anonRoutes routing.RouteRegister) { anonRoutes.Get("/devices", auth(accesscontrol.EvalPermission(accesscontrol.ActionUsersRead)), routing.Wrap(api.ListDevices)) + anonRoutes.Get("/search", auth(accesscontrol.EvalPermission(accesscontrol.ActionUsersRead)), routing.Wrap(api.SearchDevices)) }) } @@ -89,8 +91,60 @@ func (api *AnonDeviceServiceAPI) ListDevices(c *contextmodel.ReqContext) respons return response.JSON(http.StatusOK, resDevices) } +// swagger:route POST /search devices SearchDevices +// +// # Lists all devices within the last 30 days +// +// Produces: +// - application/json +// +// Responses: +// +// 200: devicesSearchResponse +// 401: unauthorisedError +// 403: forbiddenError +// 404: notFoundError +// 500: internalServerError +func (api *AnonDeviceServiceAPI) SearchDevices(c *contextmodel.ReqContext) response.Response { + perPage := c.QueryInt("perpage") + if perPage <= 0 { + perPage = 100 + } + page := c.QueryInt("page") + + if page < 1 { + page = 1 + } + + searchQuery := c.Query("query") + + sortOpts, err := sortopts.ParseSortQueryParam(c.Query("sort")) + if err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to list devices", err) + } + + // TODO: potential add from and to time to query + query := &anonstore.SearchDeviceQuery{ + Query: searchQuery, + Page: page, + Limit: perPage, + SortOpts: sortOpts, + } + results, err := api.store.SearchDevices(c.Req.Context(), query) + if err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to list devices", err) + } + return response.JSON(http.StatusOK, results) +} + // swagger:response devicesResponse type DevicesResponse struct { // in:body Body []deviceDTO `json:"body"` } + +// swagger:response devicesSearchResponse +type DevicesSearchResponse struct { + // in:body + Body anonstore.SearchDeviceQueryResult `json:"body"` +} diff --git a/pkg/services/anonymous/anonimpl/impl.go b/pkg/services/anonymous/anonimpl/impl.go index 826be4d0a90..8a7e0ac6586 100644 --- a/pkg/services/anonymous/anonimpl/impl.go +++ b/pkg/services/anonymous/anonimpl/impl.go @@ -154,6 +154,7 @@ func (a *AnonDeviceService) TagDevice(ctx context.Context, httpReq *http.Request // ListDevices returns all devices that have been updated between the given times. func (a *AnonDeviceService) ListDevices(ctx context.Context, from *time.Time, to *time.Time) ([]*anonstore.Device, error) { if !a.cfg.AnonymousEnabled { + a.log.Debug("Anonymous access is disabled, returning empty result") return []*anonstore.Device{}, nil } @@ -163,12 +164,21 @@ func (a *AnonDeviceService) ListDevices(ctx context.Context, from *time.Time, to // CountDevices returns the number of devices that have been updated between the given times. func (a *AnonDeviceService) CountDevices(ctx context.Context, from time.Time, to time.Time) (int64, error) { if !a.cfg.AnonymousEnabled { + a.log.Debug("Anonymous access is disabled, returning empty result") return 0, nil } return a.anonStore.CountDevices(ctx, from, to) } +func (a *AnonDeviceService) SearchDevices(ctx context.Context, query *anonstore.SearchDeviceQuery) (*anonstore.SearchDeviceQueryResult, error) { + if !a.cfg.AnonymousEnabled { + a.log.Debug("Anonymous access is disabled, returning empty result") + return nil, nil + } + return a.anonStore.SearchDevices(ctx, query) +} + func (a *AnonDeviceService) Run(ctx context.Context) error { ticker := time.NewTicker(2 * time.Hour) diff --git a/pkg/services/anonymous/anonimpl/impl_test.go b/pkg/services/anonymous/anonimpl/impl_test.go index 295654f93bc..37524473545 100644 --- a/pkg/services/anonymous/anonimpl/impl_test.go +++ b/pkg/services/anonymous/anonimpl/impl_test.go @@ -177,3 +177,86 @@ func TestIntegrationAnonDeviceService_localCacheSafety(t *testing.T) { assert.Equal(t, int64(0), stats["stats.anonymous.device.ui.count"].(int64)) } + +func TestIntegrationDeviceService_SearchDevice(t *testing.T) { + testCases := []struct { + name string + insertDevices []*anonstore.Device + searchQuery anonstore.SearchDeviceQuery + expectedCount int + expectedDevice *anonstore.Device + }{ + { + name: "two devices and limit set to 1", + insertDevices: []*anonstore.Device{ + { + DeviceID: "32mdo31deeqwes", + ClientIP: "", + UserAgent: "test", + UpdatedAt: time.Now().UTC(), + }, + { + DeviceID: "32mdo31deeqwes2", + ClientIP: "", + UserAgent: "test2", + UpdatedAt: time.Now().UTC(), + }, + }, + searchQuery: anonstore.SearchDeviceQuery{ + Query: "", + Page: 1, + Limit: 1, + }, + expectedCount: 1, + }, + { + name: "two devices and search for client ip 192.1", + insertDevices: []*anonstore.Device{ + { + DeviceID: "32mdo31deeqwes", + ClientIP: "192.168.0.2:10", + UserAgent: "", + UpdatedAt: time.Now().UTC(), + }, + { + DeviceID: "32mdo31deeqwes2", + ClientIP: "192.268.1.3:200", + UserAgent: "", + UpdatedAt: time.Now().UTC(), + }, + }, + searchQuery: anonstore.SearchDeviceQuery{ + Query: "192.1", + Page: 1, + Limit: 50, + }, + expectedCount: 1, + expectedDevice: &anonstore.Device{ + DeviceID: "32mdo31deeqwes", + ClientIP: "192.168.0.2:10", + UserAgent: "", + UpdatedAt: time.Now().UTC(), + }, + }, + } + store := db.InitTestDB(t) + anonService := ProvideAnonymousDeviceService(&usagestats.UsageStatsMock{}, + &authntest.FakeService{}, store, setting.NewCfg(), orgtest.NewOrgServiceFake(), nil, actest.FakeAccessControl{}, &routing.RouteRegisterImpl{}) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for _, device := range tc.insertDevices { + err := anonService.anonStore.CreateOrUpdateDevice(context.Background(), device) + require.NoError(t, err) + } + + devices, err := anonService.anonStore.SearchDevices(context.Background(), &tc.searchQuery) + require.NoError(t, err) + require.Len(t, devices.Devices, tc.expectedCount) + if tc.expectedDevice != nil { + device := devices.Devices[0] + require.Equal(t, tc.expectedDevice.UserAgent, device.UserAgent) + } + }) + } +} diff --git a/pkg/services/anonymous/sortopts/sortopts.go b/pkg/services/anonymous/sortopts/sortopts.go new file mode 100644 index 00000000000..4ebb16e4929 --- /dev/null +++ b/pkg/services/anonymous/sortopts/sortopts.go @@ -0,0 +1,97 @@ +package sortopts + +import ( + "fmt" + "sort" + "strings" + + "github.com/grafana/grafana/pkg/services/search/model" + "github.com/grafana/grafana/pkg/util/errutil" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +var ( + // SortOptionsByQueryParam is a map to translate the "sort" query param values to SortOption(s) + SortOptionsByQueryParam = map[string]model.SortOption{ + "userAgent-asc": newSortOption("user_agent", false, 0), + "userAgent-desc": newSortOption("user_agent", true, 0), + "updatedAt-asc": newTimeSortOption("updated_at", false, 1), + "updatedAt-desc": newTimeSortOption("updated_at", true, 2), + } + + ErrorUnknownSortingOption = errutil.BadRequest("unknown sorting option") +) + +type Sorter struct { + Field string + LowerCase bool + Descending bool + WithTableName bool +} + +func (s Sorter) OrderBy() string { + orderBy := "anon_device." + if !s.WithTableName { + orderBy = "" + } + orderBy += s.Field + if s.LowerCase { + orderBy = fmt.Sprintf("LOWER(%v)", orderBy) + } + if s.Descending { + return orderBy + " DESC" + } + return orderBy + " ASC" +} + +func newSortOption(field string, desc bool, index int) model.SortOption { + direction := "asc" + description := ("A-Z") + if desc { + direction = "desc" + description = ("Z-A") + } + return model.SortOption{ + Name: fmt.Sprintf("%v-%v", field, direction), + DisplayName: fmt.Sprintf("%v (%v)", cases.Title(language.Und).String(field), description), + Description: fmt.Sprintf("Sort %v in an alphabetically %vending order", field, direction), + Index: index, + Filter: []model.SortOptionFilter{Sorter{Field: field, Descending: desc}}, + } +} + +func newTimeSortOption(field string, desc bool, index int) model.SortOption { + direction := "asc" + description := ("Oldest-Newest") + if desc { + direction = "desc" + description = ("Newest-Oldest") + } + return model.SortOption{ + Name: fmt.Sprintf("%v-%v", field, direction), + DisplayName: fmt.Sprintf("%v (%v)", cases.Title(language.Und).String(field), description), + Description: fmt.Sprintf("Sort %v by time in an %vending order", field, direction), + Index: index, + Filter: []model.SortOptionFilter{Sorter{Field: field, Descending: desc}}, + } +} + +// ParseSortQueryParam parses the "sort" query param and returns an ordered list of SortOption(s) +func ParseSortQueryParam(param string) ([]model.SortOption, error) { + opts := []model.SortOption{} + if param != "" { + optsStr := strings.Split(param, ",") + for i := range optsStr { + if opt, ok := SortOptionsByQueryParam[optsStr[i]]; !ok { + return nil, ErrorUnknownSortingOption.Errorf("%v option unknown", optsStr[i]) + } else { + opts = append(opts, opt) + } + } + sort.Slice(opts, func(i, j int) bool { + return opts[i].Index < opts[j].Index || (opts[i].Index == opts[j].Index && opts[i].Name < opts[j].Name) + }) + } + return opts, nil +} diff --git a/public/api-merged.json b/public/api-merged.json index c199a3282e4..34824587b77 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -9452,6 +9452,33 @@ "$ref": "#/responses/internalServerError" } } + }, + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "devices" + ], + "summary": "Lists all devices within the last 30 days", + "operationId": "SearchDevices", + "responses": { + "200": { + "$ref": "#/responses/devicesSearchResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } } }, "/search/sorting": { @@ -14263,6 +14290,32 @@ } } }, + "DeviceSearchHitDTO": { + "type": "object", + "properties": { + "clientIp": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "deviceId": { + "type": "string" + }, + "lastSeenAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "userAgent": { + "type": "string" + } + } + }, "DiscordConfig": { "type": "object", "title": "DiscordConfig configures notifications via Discord.", @@ -19229,6 +19282,29 @@ } } }, + "SearchDeviceQueryResult": { + "type": "object", + "properties": { + "devices": { + "type": "array", + "items": { + "$ref": "#/definitions/DeviceSearchHitDTO" + } + }, + "page": { + "type": "integer", + "format": "int64" + }, + "perPage": { + "type": "integer", + "format": "int64" + }, + "totalCount": { + "type": "integer", + "format": "int64" + } + } + }, "SearchOrgServiceAccountsResult": { "description": "swagger: model", "type": "object", @@ -22431,6 +22507,12 @@ } } }, + "devicesSearchResponse": { + "description": "(empty)", + "schema": { + "$ref": "#/definitions/SearchDeviceQueryResult" + } + }, "folderResponse": { "description": "(empty)", "schema": { diff --git a/public/app/features/admin/UserListAnonymousPage.tsx b/public/app/features/admin/UserListAnonymousPage.tsx index a452fa5a90a..3d4355653a9 100644 --- a/public/app/features/admin/UserListAnonymousPage.tsx +++ b/public/app/features/admin/UserListAnonymousPage.tsx @@ -1,35 +1,85 @@ +import { css } from '@emotion/css'; import React, { useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; +import { GrafanaTheme2 } from '@grafana/data'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { RadioButtonGroup, useStyles2, FilterInput } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { StoreState } from '../../types'; import { AnonUsersDevicesTable } from './Users/AnonUsersTable'; -import { fetchUsersAnonymousDevices } from './state/actions'; +import { fetchUsersAnonymousDevices, changeAnonUserSort, changeAnonPage, changeAnonQuery } from './state/actions'; const mapDispatchToProps = { fetchUsersAnonymousDevices, + changeAnonUserSort, + changeAnonPage, + changeAnonQuery, }; const mapStateToProps = (state: StoreState) => ({ devices: state.userListAnonymousDevices.devices, + query: state.userListAnonymousDevices.query, + showPaging: state.userListAnonymousDevices.showPaging, + totalPages: state.userListAnonymousDevices.totalPages, + page: state.userListAnonymousDevices.page, + filters: state.userListAnonymousDevices.filters, }); +const selectors = e2eSelectors.pages.UserListPage.UserListAdminPage; + const connector = connect(mapStateToProps, mapDispatchToProps); interface OwnProps {} type Props = OwnProps & ConnectedProps; -const UserListAnonymousDevicesPageUnConnected = ({ devices, fetchUsersAnonymousDevices }: Props) => { +const UserListAnonymousDevicesPageUnConnected = ({ + devices, + fetchUsersAnonymousDevices, + query, + changeAnonQuery, + filters, + showPaging, + totalPages, + page, + changeAnonPage, + changeAnonUserSort, +}: Props) => { + const styles = useStyles2(getStyles); + useEffect(() => { fetchUsersAnonymousDevices(); }, [fetchUsersAnonymousDevices]); return ( - +
+
+ + changeFilter({ name: 'activeLast30Days', value })} + value={filters.find((f) => f.name === 'activeLast30Days')?.value} + className={styles.filter} + /> +
+
+
); }; @@ -44,4 +94,37 @@ export function UserListAnonymousDevicesPage() { ); } +const getStyles = (theme: GrafanaTheme2) => { + return { + filter: css({ + margin: theme.spacing(0, 1), + [theme.breakpoints.down('sm')]: { + margin: 0, + }, + }), + actionBar: css({ + marginBottom: theme.spacing(2), + display: 'flex', + alignItems: 'flex-start', + gap: theme.spacing(2), + [theme.breakpoints.down('sm')]: { + flexWrap: 'wrap', + }, + }), + row: css({ + display: 'flex', + alignItems: 'flex-start', + textAlign: 'left', + marginBottom: theme.spacing(0.5), + flexGrow: 1, + + [theme.breakpoints.down('sm')]: { + flexWrap: 'wrap', + gap: theme.spacing(2), + width: '100%', + }, + }), + }; +}; + export default UserListAnonymousDevicesPage; diff --git a/public/app/features/admin/Users/AnonUsersTable.tsx b/public/app/features/admin/Users/AnonUsersTable.tsx index f2ecd65c5a1..75c7f96aa78 100644 --- a/public/app/features/admin/Users/AnonUsersTable.tsx +++ b/public/app/features/admin/Users/AnonUsersTable.tsx @@ -1,6 +1,16 @@ import React, { useMemo } from 'react'; -import { Avatar, CellProps, Column, InteractiveTable, Stack, Badge, Tooltip } from '@grafana/ui'; +import { + Avatar, + CellProps, + Column, + InteractiveTable, + Stack, + Badge, + Tooltip, + Pagination, + FetchDataFunc, +} from '@grafana/ui'; import { EmptyArea } from 'app/features/alerting/unified/components/EmptyArea'; import { UserAnonymousDeviceDTO } from 'app/types'; @@ -49,9 +59,22 @@ const UserAgentCell = ({ value }: UserAgentCellProps) => { interface AnonUsersTableProps { devices: UserAnonymousDeviceDTO[]; + // for pagination + showPaging?: boolean; + totalPages: number; + onChangePage: (page: number) => void; + currentPage: number; + fetchData?: FetchDataFunc; } -export const AnonUsersDevicesTable = ({ devices }: AnonUsersTableProps) => { +export const AnonUsersDevicesTable = ({ + devices, + showPaging, + totalPages, + onChangePage, + currentPage, + fetchData, +}: AnonUsersTableProps) => { const columns: Array> = useMemo( () => [ { @@ -71,9 +94,9 @@ export const AnonUsersDevicesTable = ({ devices }: AnonUsersTableProps) => { sortType: 'string', }, { - id: 'lastSeenAt', + id: 'updatedAt', header: 'Last active', - cell: ({ cell: { value } }: Cell<'lastSeenAt'>) => value, + cell: ({ cell: { value } }: Cell<'updatedAt'>) => value, sortType: (a, b) => new Date(a.original.updatedAt).getTime() - new Date(b.original.updatedAt).getTime(), }, { @@ -86,7 +109,12 @@ export const AnonUsersDevicesTable = ({ devices }: AnonUsersTableProps) => { ); return ( - user.deviceId} /> + user.deviceId} fetchData={fetchData} /> + {showPaging && ( + + + + )} {devices.length === 0 && ( No anonymous users found. diff --git a/public/app/features/admin/state/actions.ts b/public/app/features/admin/state/actions.ts index eaca44d51cd..e83e9f188fe 100644 --- a/public/app/features/admin/state/actions.ts +++ b/public/app/features/admin/state/actions.ts @@ -6,7 +6,15 @@ import { FetchDataArgs } from '@grafana/ui'; import config from 'app/core/config'; import { contextSrv } from 'app/core/core'; import { accessControlQueryParam } from 'app/core/utils/accessControl'; -import { ThunkResult, LdapUser, UserSession, UserDTO, AccessControlAction, UserFilter } from 'app/types'; +import { + ThunkResult, + LdapUser, + UserSession, + UserDTO, + AccessControlAction, + UserFilter, + AnonUserFilter, +} from 'app/types'; import { userAdminPageLoadedAction, @@ -29,6 +37,9 @@ import { usersFetchEnd, sortChanged, usersAnonymousDevicesFetched, + anonUserSortChanged, + anonPageChanged, + anonQueryChanged, } from './reducers'; // UserAdminPage @@ -337,16 +348,72 @@ export function changeSort({ sortBy }: FetchDataArgs): ThunkResult { + return filters + .map((filter) => { + if (Array.isArray(filter.value)) { + return filter.value.map((v) => `${filter.name}=${v.value}`).join('&'); + } + return `${filter.name}=${filter.value}`; + }) + .join('&'); +}; export function fetchUsersAnonymousDevices(): ThunkResult { return async (dispatch, getState) => { try { - let url = `/api/anonymous/devices`; + const { perPage, page, query, filters, sort } = getState().userListAnonymousDevices; + let url = `/api/anonymous/search?perpage=${perPage}&page=${page}&query=${query}&${getAnonFilters(filters)}`; + if (sort) { + url += `&sort=${sort}`; + } const result = await getBackendSrv().get(url); - dispatch(usersAnonymousDevicesFetched({ devices: result })); + dispatch(usersAnonymousDevicesFetched(result)); } catch (error) { - usersFetchEnd(); console.error(error); } }; } + +const fetchAnonUsersWithDebounce = debounce((dispatch) => dispatch(fetchUsersAnonymousDevices()), 500); + +export function changeAnonUserSort({ sortBy }: FetchDataArgs): ThunkResult { + const sort = sortBy.length ? `${sortBy[0].id}-${sortBy[0].desc ? 'desc' : 'asc'}` : undefined; + return async (dispatch, getState) => { + const currentSort = getState().userListAnonymousDevices.sort; + if (currentSort !== sort) { + // dispatch(usersFetchBegin()); + dispatch(anonUserSortChanged(sort)); + dispatch(fetchUsersAnonymousDevices()); + } + }; +} + +export function changeAnonQuery(query: string): ThunkResult { + return async (dispatch) => { + // dispatch(usersFetchBegin()); + dispatch(anonQueryChanged(query)); + fetchAnonUsersWithDebounce(dispatch); + }; +} + +export function changeAnonPage(page: number): ThunkResult { + return async (dispatch) => { + // dispatch(usersFetchBegin()); + dispatch(anonPageChanged(page)); + dispatch(fetchUsersAnonymousDevices()); + }; +} + +// export function fetchUsersAnonymousDevices(): ThunkResult { +// return async (dispatch, getState) => { +// try { +// let url = `/api/anonymous/devices`; +// const result = await getBackendSrv().get(url); +// dispatch(usersAnonymousDevicesFetched({ devices: result })); +// } catch (error) { +// usersFetchEnd(); +// console.error(error); +// } +// }; +// } diff --git a/public/app/features/admin/state/reducers.ts b/public/app/features/admin/state/reducers.ts index 68af1117cbf..550e3de57b6 100644 --- a/public/app/features/admin/state/reducers.ts +++ b/public/app/features/admin/state/reducers.ts @@ -15,6 +15,7 @@ import { UserFilter, UserListAnonymousDevicesState, UserAnonymousDeviceDTO, + AnonUserFilter, } from 'app/types'; const initialLdapState: LdapState = { @@ -207,10 +208,19 @@ export const userListAdminReducer = userListAdminSlice.reducer; const initialUserListAnonymousDevicesState: UserListAnonymousDevicesState = { devices: [], + query: '', + page: 0, + perPage: 50, + totalPages: 1, + showPaging: false, + filters: [{ name: 'activeLast30Days', value: true }], }; interface UsersAnonymousDevicesFetched { devices: UserAnonymousDeviceDTO[]; + perPage: number; + page: number; + totalCount: number; } export const userListAnonymousDevicesSlice = createSlice({ @@ -218,17 +228,52 @@ export const userListAnonymousDevicesSlice = createSlice({ initialState: initialUserListAnonymousDevicesState, reducers: { usersAnonymousDevicesFetched: (state, action: PayloadAction) => { - const { devices } = action.payload; + const { totalCount, perPage, ...rest } = action.payload; + const totalPages = Math.ceil(totalCount / perPage); + return { ...state, - devices, - isLoading: false, + ...rest, + totalPages, + perPage, + showPaging: totalPages > 1, + }; + }, + anonQueryChanged: (state, action: PayloadAction) => ({ + ...state, + query: action.payload, + page: 0, + }), + anonPageChanged: (state, action: PayloadAction) => ({ + ...state, + page: action.payload, + }), + anonUserSortChanged: (state, action: PayloadAction) => ({ + ...state, + page: 0, + sort: action.payload, + }), + filterChanged: (state, action: PayloadAction) => { + const { name, value } = action.payload; + + if (state.filters.some((filter) => filter.name === name)) { + return { + ...state, + page: 0, + filters: state.filters.map((filter) => (filter.name === name ? { ...filter, value } : filter)), + }; + } + return { + ...state, + page: 0, + filters: [...state.filters, action.payload], }; }, }, }); -export const { usersAnonymousDevicesFetched } = userListAnonymousDevicesSlice.actions; +export const { usersAnonymousDevicesFetched, anonUserSortChanged, anonPageChanged, anonQueryChanged } = + userListAnonymousDevicesSlice.actions; export const userListAnonymousDevicesReducer = userListAnonymousDevicesSlice.reducer; export default { diff --git a/public/app/types/user.ts b/public/app/types/user.ts index 1c300befa1a..8731658c6f5 100644 --- a/public/app/types/user.ts +++ b/public/app/types/user.ts @@ -142,6 +142,15 @@ export interface UserAnonymousDeviceDTO { avatarUrl?: string; } +export type AnonUserFilter = Record; + export interface UserListAnonymousDevicesState { devices: UserAnonymousDeviceDTO[]; + query: string; + perPage: number; + page: number; + totalPages: number; + showPaging: boolean; + filters: AnonUserFilter[]; + sort?: string; } diff --git a/public/openapi3.json b/public/openapi3.json index a791047b042..f924394f4ad 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -519,6 +519,16 @@ }, "description": "(empty)" }, + "devicesSearchResponse": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchDeviceQueryResult" + } + } + }, + "description": "(empty)" + }, "folderResponse": { "content": { "application/json": { @@ -4794,6 +4804,32 @@ }, "type": "object" }, + "DeviceSearchHitDTO": { + "properties": { + "clientIp": { + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "deviceId": { + "type": "string" + }, + "lastSeenAt": { + "format": "date-time", + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + }, + "userAgent": { + "type": "string" + } + }, + "type": "object" + }, "DiscordConfig": { "properties": { "http_config": { @@ -9759,6 +9795,29 @@ }, "type": "object" }, + "SearchDeviceQueryResult": { + "properties": { + "devices": { + "items": { + "$ref": "#/components/schemas/DeviceSearchHitDTO" + }, + "type": "array" + }, + "page": { + "format": "int64", + "type": "integer" + }, + "perPage": { + "format": "int64", + "type": "integer" + }, + "totalCount": { + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, "SearchOrgServiceAccountsResult": { "description": "swagger: model", "properties": { @@ -22921,6 +22980,30 @@ "tags": [ "search" ] + }, + "post": { + "operationId": "SearchDevices", + "responses": { + "200": { + "$ref": "#/components/responses/devicesSearchResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Lists all devices within the last 30 days", + "tags": [ + "devices" + ] } }, "/search/sorting": {