mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
e2b706fdd3
commit
3979ea0c47
@ -329,6 +329,9 @@ export const Pages = {
|
|||||||
UsersListPage: {
|
UsersListPage: {
|
||||||
container: 'data-testid users-list-page',
|
container: 'data-testid users-list-page',
|
||||||
},
|
},
|
||||||
|
UserAnonListPage: {
|
||||||
|
container: 'data-testid user-anon-list-page',
|
||||||
|
},
|
||||||
UsersListPublicDashboardsPage: {
|
UsersListPublicDashboardsPage: {
|
||||||
container: 'data-testid users-list-public-dashboards-page',
|
container: 'data-testid users-list-public-dashboards-page',
|
||||||
DashboardsListModal: {
|
DashboardsListModal: {
|
||||||
|
@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/grafana/grafana/pkg/infra/db"
|
"github.com/grafana/grafana/pkg/infra/db"
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"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"
|
||||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
"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"`
|
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 {
|
func (a *Device) CacheKey() string {
|
||||||
return strings.Join([]string{cacheKeyPrefix, a.DeviceID}, ":")
|
return strings.Join([]string{cacheKeyPrefix, a.DeviceID}, ":")
|
||||||
}
|
}
|
||||||
@ -47,6 +72,8 @@ type AnonStore interface {
|
|||||||
DeleteDevice(ctx context.Context, deviceID string) error
|
DeleteDevice(ctx context.Context, deviceID string) error
|
||||||
// DeleteDevicesOlderThan deletes all devices that have no been updated since the given time.
|
// DeleteDevicesOlderThan deletes all devices that have no been updated since the given time.
|
||||||
DeleteDevicesOlderThan(ctx context.Context, olderThan time.Time) error
|
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 {
|
func ProvideAnonDBStore(sqlStore db.DB, deviceLimit int64) *AnonDBStore {
|
||||||
@ -183,3 +210,64 @@ func (s *AnonDBStore) DeleteDevicesOlderThan(ctx context.Context, olderThan time
|
|||||||
|
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
@ -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) {
|
func (s *FakeAnonStore) CountDevices(ctx context.Context, from time.Time, to time.Time) (int64, error) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *FakeAnonStore) SearchDevices(ctx context.Context, query SearchDeviceQuery) (*SearchDeviceQueryResult, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
"github.com/grafana/grafana/pkg/services/anonymous/anonimpl/anonstore"
|
"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"
|
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/grafana/grafana/pkg/util"
|
"github.com/grafana/grafana/pkg/util"
|
||||||
@ -50,6 +51,7 @@ func (api *AnonDeviceServiceAPI) RegisterAPIEndpoints() {
|
|||||||
auth := accesscontrol.Middleware(api.accesscontrol)
|
auth := accesscontrol.Middleware(api.accesscontrol)
|
||||||
api.RouterRegister.Group("/api/anonymous", func(anonRoutes routing.RouteRegister) {
|
api.RouterRegister.Group("/api/anonymous", func(anonRoutes routing.RouteRegister) {
|
||||||
anonRoutes.Get("/devices", auth(accesscontrol.EvalPermission(accesscontrol.ActionUsersRead)), routing.Wrap(api.ListDevices))
|
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)
|
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
|
// swagger:response devicesResponse
|
||||||
type DevicesResponse struct {
|
type DevicesResponse struct {
|
||||||
// in:body
|
// in:body
|
||||||
Body []deviceDTO `json:"body"`
|
Body []deviceDTO `json:"body"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// swagger:response devicesSearchResponse
|
||||||
|
type DevicesSearchResponse struct {
|
||||||
|
// in:body
|
||||||
|
Body anonstore.SearchDeviceQueryResult `json:"body"`
|
||||||
|
}
|
||||||
|
@ -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.
|
// 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) {
|
func (a *AnonDeviceService) ListDevices(ctx context.Context, from *time.Time, to *time.Time) ([]*anonstore.Device, error) {
|
||||||
if !a.cfg.AnonymousEnabled {
|
if !a.cfg.AnonymousEnabled {
|
||||||
|
a.log.Debug("Anonymous access is disabled, returning empty result")
|
||||||
return []*anonstore.Device{}, nil
|
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.
|
// 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) {
|
func (a *AnonDeviceService) CountDevices(ctx context.Context, from time.Time, to time.Time) (int64, error) {
|
||||||
if !a.cfg.AnonymousEnabled {
|
if !a.cfg.AnonymousEnabled {
|
||||||
|
a.log.Debug("Anonymous access is disabled, returning empty result")
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.anonStore.CountDevices(ctx, from, to)
|
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 {
|
func (a *AnonDeviceService) Run(ctx context.Context) error {
|
||||||
ticker := time.NewTicker(2 * time.Hour)
|
ticker := time.NewTicker(2 * time.Hour)
|
||||||
|
|
||||||
|
@ -177,3 +177,86 @@ func TestIntegrationAnonDeviceService_localCacheSafety(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, int64(0), stats["stats.anonymous.device.ui.count"].(int64))
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
97
pkg/services/anonymous/sortopts/sortopts.go
Normal file
97
pkg/services/anonymous/sortopts/sortopts.go
Normal file
@ -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
|
||||||
|
}
|
@ -9452,6 +9452,33 @@
|
|||||||
"$ref": "#/responses/internalServerError"
|
"$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": {
|
"/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": {
|
"DiscordConfig": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "DiscordConfig configures notifications via Discord.",
|
"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": {
|
"SearchOrgServiceAccountsResult": {
|
||||||
"description": "swagger: model",
|
"description": "swagger: model",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@ -22431,6 +22507,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"devicesSearchResponse": {
|
||||||
|
"description": "(empty)",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/SearchDeviceQueryResult"
|
||||||
|
}
|
||||||
|
},
|
||||||
"folderResponse": {
|
"folderResponse": {
|
||||||
"description": "(empty)",
|
"description": "(empty)",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
@ -1,35 +1,85 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { connect, ConnectedProps } from 'react-redux';
|
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 { Page } from 'app/core/components/Page/Page';
|
||||||
|
|
||||||
import { StoreState } from '../../types';
|
import { StoreState } from '../../types';
|
||||||
|
|
||||||
import { AnonUsersDevicesTable } from './Users/AnonUsersTable';
|
import { AnonUsersDevicesTable } from './Users/AnonUsersTable';
|
||||||
import { fetchUsersAnonymousDevices } from './state/actions';
|
import { fetchUsersAnonymousDevices, changeAnonUserSort, changeAnonPage, changeAnonQuery } from './state/actions';
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
fetchUsersAnonymousDevices,
|
fetchUsersAnonymousDevices,
|
||||||
|
changeAnonUserSort,
|
||||||
|
changeAnonPage,
|
||||||
|
changeAnonQuery,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: StoreState) => ({
|
const mapStateToProps = (state: StoreState) => ({
|
||||||
devices: state.userListAnonymousDevices.devices,
|
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);
|
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||||
|
|
||||||
interface OwnProps {}
|
interface OwnProps {}
|
||||||
|
|
||||||
type Props = OwnProps & ConnectedProps<typeof connector>;
|
type Props = OwnProps & ConnectedProps<typeof connector>;
|
||||||
|
|
||||||
const UserListAnonymousDevicesPageUnConnected = ({ devices, fetchUsersAnonymousDevices }: Props) => {
|
const UserListAnonymousDevicesPageUnConnected = ({
|
||||||
|
devices,
|
||||||
|
fetchUsersAnonymousDevices,
|
||||||
|
query,
|
||||||
|
changeAnonQuery,
|
||||||
|
filters,
|
||||||
|
showPaging,
|
||||||
|
totalPages,
|
||||||
|
page,
|
||||||
|
changeAnonPage,
|
||||||
|
changeAnonUserSort,
|
||||||
|
}: Props) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUsersAnonymousDevices();
|
fetchUsersAnonymousDevices();
|
||||||
}, [fetchUsersAnonymousDevices]);
|
}, [fetchUsersAnonymousDevices]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page.Contents>
|
<Page.Contents>
|
||||||
<AnonUsersDevicesTable devices={devices} />
|
<div className={styles.actionBar} data-testid={selectors.container}>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<FilterInput
|
||||||
|
placeholder="Search devices by ip adress."
|
||||||
|
autoFocus={true}
|
||||||
|
value={query}
|
||||||
|
onChange={changeAnonQuery}
|
||||||
|
/>
|
||||||
|
<RadioButtonGroup
|
||||||
|
options={[{ label: 'Active last 30 days', value: true }]}
|
||||||
|
// onChange={(value) => changeFilter({ name: 'activeLast30Days', value })}
|
||||||
|
value={filters.find((f) => f.name === 'activeLast30Days')?.value}
|
||||||
|
className={styles.filter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AnonUsersDevicesTable
|
||||||
|
devices={devices}
|
||||||
|
showPaging={showPaging}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onChangePage={changeAnonPage}
|
||||||
|
currentPage={page}
|
||||||
|
fetchData={changeAnonUserSort}
|
||||||
|
/>
|
||||||
</Page.Contents>
|
</Page.Contents>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -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;
|
export default UserListAnonymousDevicesPage;
|
||||||
|
@ -1,6 +1,16 @@
|
|||||||
import React, { useMemo } from 'react';
|
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 { EmptyArea } from 'app/features/alerting/unified/components/EmptyArea';
|
||||||
import { UserAnonymousDeviceDTO } from 'app/types';
|
import { UserAnonymousDeviceDTO } from 'app/types';
|
||||||
|
|
||||||
@ -49,9 +59,22 @@ const UserAgentCell = ({ value }: UserAgentCellProps) => {
|
|||||||
|
|
||||||
interface AnonUsersTableProps {
|
interface AnonUsersTableProps {
|
||||||
devices: UserAnonymousDeviceDTO[];
|
devices: UserAnonymousDeviceDTO[];
|
||||||
|
// for pagination
|
||||||
|
showPaging?: boolean;
|
||||||
|
totalPages: number;
|
||||||
|
onChangePage: (page: number) => void;
|
||||||
|
currentPage: number;
|
||||||
|
fetchData?: FetchDataFunc<UserAnonymousDeviceDTO>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AnonUsersDevicesTable = ({ devices }: AnonUsersTableProps) => {
|
export const AnonUsersDevicesTable = ({
|
||||||
|
devices,
|
||||||
|
showPaging,
|
||||||
|
totalPages,
|
||||||
|
onChangePage,
|
||||||
|
currentPage,
|
||||||
|
fetchData,
|
||||||
|
}: AnonUsersTableProps) => {
|
||||||
const columns: Array<Column<UserAnonymousDeviceDTO>> = useMemo(
|
const columns: Array<Column<UserAnonymousDeviceDTO>> = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@ -71,9 +94,9 @@ export const AnonUsersDevicesTable = ({ devices }: AnonUsersTableProps) => {
|
|||||||
sortType: 'string',
|
sortType: 'string',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'lastSeenAt',
|
id: 'updatedAt',
|
||||||
header: 'Last active',
|
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(),
|
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 (
|
return (
|
||||||
<Stack direction={'column'} gap={2}>
|
<Stack direction={'column'} gap={2}>
|
||||||
<InteractiveTable columns={columns} data={devices} getRowId={(user) => user.deviceId} />
|
<InteractiveTable columns={columns} data={devices} getRowId={(user) => user.deviceId} fetchData={fetchData} />
|
||||||
|
{showPaging && (
|
||||||
|
<Stack justifyContent={'flex-end'}>
|
||||||
|
<Pagination numberOfPages={totalPages} currentPage={currentPage} onNavigate={onChangePage} />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
{devices.length === 0 && (
|
{devices.length === 0 && (
|
||||||
<EmptyArea>
|
<EmptyArea>
|
||||||
<span>No anonymous users found.</span>
|
<span>No anonymous users found.</span>
|
||||||
|
@ -6,7 +6,15 @@ import { FetchDataArgs } from '@grafana/ui';
|
|||||||
import config from 'app/core/config';
|
import config from 'app/core/config';
|
||||||
import { contextSrv } from 'app/core/core';
|
import { contextSrv } from 'app/core/core';
|
||||||
import { accessControlQueryParam } from 'app/core/utils/accessControl';
|
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 {
|
import {
|
||||||
userAdminPageLoadedAction,
|
userAdminPageLoadedAction,
|
||||||
@ -29,6 +37,9 @@ import {
|
|||||||
usersFetchEnd,
|
usersFetchEnd,
|
||||||
sortChanged,
|
sortChanged,
|
||||||
usersAnonymousDevicesFetched,
|
usersAnonymousDevicesFetched,
|
||||||
|
anonUserSortChanged,
|
||||||
|
anonPageChanged,
|
||||||
|
anonQueryChanged,
|
||||||
} from './reducers';
|
} from './reducers';
|
||||||
// UserAdminPage
|
// UserAdminPage
|
||||||
|
|
||||||
@ -337,16 +348,72 @@ export function changeSort({ sortBy }: FetchDataArgs<UserDTO>): ThunkResult<void
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UserListAnonymousPage
|
// UserListAnonymousPage
|
||||||
|
const getAnonFilters = (filters: AnonUserFilter[]) => {
|
||||||
|
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<void> {
|
export function fetchUsersAnonymousDevices(): ThunkResult<void> {
|
||||||
return async (dispatch, getState) => {
|
return async (dispatch, getState) => {
|
||||||
try {
|
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);
|
const result = await getBackendSrv().get(url);
|
||||||
dispatch(usersAnonymousDevicesFetched({ devices: result }));
|
dispatch(usersAnonymousDevicesFetched(result));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
usersFetchEnd();
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchAnonUsersWithDebounce = debounce((dispatch) => dispatch(fetchUsersAnonymousDevices()), 500);
|
||||||
|
|
||||||
|
export function changeAnonUserSort({ sortBy }: FetchDataArgs<UserDTO>): ThunkResult<void> {
|
||||||
|
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<void> {
|
||||||
|
return async (dispatch) => {
|
||||||
|
// dispatch(usersFetchBegin());
|
||||||
|
dispatch(anonQueryChanged(query));
|
||||||
|
fetchAnonUsersWithDebounce(dispatch);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changeAnonPage(page: number): ThunkResult<void> {
|
||||||
|
return async (dispatch) => {
|
||||||
|
// dispatch(usersFetchBegin());
|
||||||
|
dispatch(anonPageChanged(page));
|
||||||
|
dispatch(fetchUsersAnonymousDevices());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// export function fetchUsersAnonymousDevices(): ThunkResult<void> {
|
||||||
|
// 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);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
@ -15,6 +15,7 @@ import {
|
|||||||
UserFilter,
|
UserFilter,
|
||||||
UserListAnonymousDevicesState,
|
UserListAnonymousDevicesState,
|
||||||
UserAnonymousDeviceDTO,
|
UserAnonymousDeviceDTO,
|
||||||
|
AnonUserFilter,
|
||||||
} from 'app/types';
|
} from 'app/types';
|
||||||
|
|
||||||
const initialLdapState: LdapState = {
|
const initialLdapState: LdapState = {
|
||||||
@ -207,10 +208,19 @@ export const userListAdminReducer = userListAdminSlice.reducer;
|
|||||||
|
|
||||||
const initialUserListAnonymousDevicesState: UserListAnonymousDevicesState = {
|
const initialUserListAnonymousDevicesState: UserListAnonymousDevicesState = {
|
||||||
devices: [],
|
devices: [],
|
||||||
|
query: '',
|
||||||
|
page: 0,
|
||||||
|
perPage: 50,
|
||||||
|
totalPages: 1,
|
||||||
|
showPaging: false,
|
||||||
|
filters: [{ name: 'activeLast30Days', value: true }],
|
||||||
};
|
};
|
||||||
|
|
||||||
interface UsersAnonymousDevicesFetched {
|
interface UsersAnonymousDevicesFetched {
|
||||||
devices: UserAnonymousDeviceDTO[];
|
devices: UserAnonymousDeviceDTO[];
|
||||||
|
perPage: number;
|
||||||
|
page: number;
|
||||||
|
totalCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userListAnonymousDevicesSlice = createSlice({
|
export const userListAnonymousDevicesSlice = createSlice({
|
||||||
@ -218,17 +228,52 @@ export const userListAnonymousDevicesSlice = createSlice({
|
|||||||
initialState: initialUserListAnonymousDevicesState,
|
initialState: initialUserListAnonymousDevicesState,
|
||||||
reducers: {
|
reducers: {
|
||||||
usersAnonymousDevicesFetched: (state, action: PayloadAction<UsersAnonymousDevicesFetched>) => {
|
usersAnonymousDevicesFetched: (state, action: PayloadAction<UsersAnonymousDevicesFetched>) => {
|
||||||
const { devices } = action.payload;
|
const { totalCount, perPage, ...rest } = action.payload;
|
||||||
|
const totalPages = Math.ceil(totalCount / perPage);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
devices,
|
...rest,
|
||||||
isLoading: false,
|
totalPages,
|
||||||
|
perPage,
|
||||||
|
showPaging: totalPages > 1,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
anonQueryChanged: (state, action: PayloadAction<string>) => ({
|
||||||
|
...state,
|
||||||
|
query: action.payload,
|
||||||
|
page: 0,
|
||||||
|
}),
|
||||||
|
anonPageChanged: (state, action: PayloadAction<number>) => ({
|
||||||
|
...state,
|
||||||
|
page: action.payload,
|
||||||
|
}),
|
||||||
|
anonUserSortChanged: (state, action: PayloadAction<UserListAnonymousDevicesState['sort']>) => ({
|
||||||
|
...state,
|
||||||
|
page: 0,
|
||||||
|
sort: action.payload,
|
||||||
|
}),
|
||||||
|
filterChanged: (state, action: PayloadAction<AnonUserFilter>) => {
|
||||||
|
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 const userListAnonymousDevicesReducer = userListAnonymousDevicesSlice.reducer;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -142,6 +142,15 @@ export interface UserAnonymousDeviceDTO {
|
|||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AnonUserFilter = Record<string, string | boolean | SelectableValue[]>;
|
||||||
|
|
||||||
export interface UserListAnonymousDevicesState {
|
export interface UserListAnonymousDevicesState {
|
||||||
devices: UserAnonymousDeviceDTO[];
|
devices: UserAnonymousDeviceDTO[];
|
||||||
|
query: string;
|
||||||
|
perPage: number;
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
showPaging: boolean;
|
||||||
|
filters: AnonUserFilter[];
|
||||||
|
sort?: string;
|
||||||
}
|
}
|
||||||
|
@ -519,6 +519,16 @@
|
|||||||
},
|
},
|
||||||
"description": "(empty)"
|
"description": "(empty)"
|
||||||
},
|
},
|
||||||
|
"devicesSearchResponse": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SearchDeviceQueryResult"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "(empty)"
|
||||||
|
},
|
||||||
"folderResponse": {
|
"folderResponse": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@ -4794,6 +4804,32 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"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": {
|
"DiscordConfig": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"http_config": {
|
"http_config": {
|
||||||
@ -9759,6 +9795,29 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"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": {
|
"SearchOrgServiceAccountsResult": {
|
||||||
"description": "swagger: model",
|
"description": "swagger: model",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -22921,6 +22980,30 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"search"
|
"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": {
|
"/search/sorting": {
|
||||||
|
Loading…
Reference in New Issue
Block a user