From 59bdff0280d52ca5d8918157d7697b9279b25501 Mon Sep 17 00:00:00 2001 From: Eric Leijonmarck Date: Wed, 29 Nov 2023 16:58:41 +0000 Subject: [PATCH] Auth: Add anonymous users view and stats (#78685) * Add anonymous stats and user table - anonymous users users page - add feature toggle `anonymousAccess` - remove check for enterprise for `Device-Id` header in request - add anonusers/device count to stats * promise all, review comments * make use of promise all settled * refactoring: devices instead of users * review comments, moved countdevices to httpserver * fakeAnonService for tests and generate openapi spec * do not commit openapi3 and api-merged * add openapi * Apply suggestions from code review Co-authored-by: Alex Khomenko * formatin * precise anon devices to avoid confusion --------- Co-authored-by: Alex Khomenko Co-authored-by: jguer --- .../feature-toggles/index.md | 1 + .../src/types/featureToggles.gen.ts | 1 + .../src/selectors/pages.ts | 1 + pkg/api/admin.go | 7 ++ pkg/api/admin_test.go | 9 +- pkg/api/http_server.go | 5 +- .../anonymous/anonimpl/anonstore/database.go | 10 +- pkg/services/anonymous/anonimpl/api/api.go | 98 +++++++++++++++++++ .../anonymous/anonimpl/client_test.go | 2 +- pkg/services/anonymous/anonimpl/impl.go | 18 +++- pkg/services/anonymous/anonimpl/impl_test.go | 6 +- pkg/services/anonymous/anontest/fake.go | 16 ++- pkg/services/anonymous/service.go | 2 + pkg/services/featuremgmt/registry.go | 7 ++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/stats/models.go | 4 + pkg/services/stats/statstest/stats.go | 3 +- public/api-merged.json | 70 +++++++++++++ public/app/core/services/backend_srv.ts | 8 +- .../app/features/admin/ServerStats.test.tsx | 11 +++ public/app/features/admin/ServerStats.tsx | 7 ++ .../features/admin/UserListAnonymousPage.tsx | 47 +++++++++ public/app/features/admin/UserListPage.tsx | 11 +++ .../features/admin/Users/AnonUsersTable.tsx | 82 ++++++++++++++++ public/app/features/admin/state/actions.ts | 16 +++ public/app/features/admin/state/apis.tsx | 6 +- public/app/features/admin/state/reducers.ts | 31 ++++++ public/app/types/user.ts | 14 +++ public/openapi3.json | 71 ++++++++++++++ 30 files changed, 548 insertions(+), 21 deletions(-) create mode 100644 pkg/services/anonymous/anonimpl/api/api.go create mode 100644 public/app/features/admin/UserListAnonymousPage.tsx create mode 100644 public/app/features/admin/Users/AnonUsersTable.tsx diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 6d1cde12d95..02aba0cd42e 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -165,6 +165,7 @@ Experimental features might be changed or removed without prior notice. | `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected | | `pluginsSkipHostEnvVars` | Disables passing host environment variable to plugin processes | | `regressionTransformation` | Enables regression analysis transformation | +| `displayAnonymousStats` | Enables anonymous stats to be shown in the UI for Grafana | ## Development feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 0b730107806..7c82a1c5baf 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -165,4 +165,5 @@ export interface FeatureToggles { logRowsPopoverMenu?: boolean; pluginsSkipHostEnvVars?: boolean; regressionTransformation?: boolean; + displayAnonymousStats?: boolean; } diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index e73278d243e..7072be97a73 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -310,6 +310,7 @@ export const Pages = { tabs: { allUsers: 'data-testid all-users-tab', orgUsers: 'data-testid org-users-tab', + anonUserDevices: 'data-testid anon-user-devices-tab', publicDashboardsUsers: 'data-testid public-dashboards-users-tab', users: 'data-testid users-tab', }, diff --git a/pkg/api/admin.go b/pkg/api/admin.go index d0ccd476cc1..d88818325e3 100644 --- a/pkg/api/admin.go +++ b/pkg/api/admin.go @@ -3,6 +3,7 @@ package api import ( "context" "net/http" + "time" "github.com/grafana/grafana/pkg/api/response" ac "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -63,6 +64,12 @@ func (hs *HTTPServer) AdminGetStats(c *contextmodel.ReqContext) response.Respons if err != nil { return response.Error(500, "Failed to get admin stats from database", err) } + thirtyDays := 30 * 24 * time.Hour + devicesCount, err := hs.anonService.CountDevices(c.Req.Context(), time.Now().Add(-thirtyDays), time.Now().Add(time.Minute)) + if err != nil { + return response.Error(500, "Failed to get anon stats from database", err) + } + adminStats.AnonymousStats.ActiveDevices = devicesCount return response.JSON(http.StatusOK, adminStats) } diff --git a/pkg/api/admin_test.go b/pkg/api/admin_test.go index 04c69f4755b..90227573bac 100644 --- a/pkg/api/admin_test.go +++ b/pkg/api/admin_test.go @@ -10,6 +10,8 @@ import ( "github.com/grafana/grafana/pkg/infra/db/dbtest" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/anonymous/anontest" + "github.com/grafana/grafana/pkg/services/stats" "github.com/grafana/grafana/pkg/services/stats/statstest" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web/webtest" @@ -150,11 +152,16 @@ func TestAdmin_AccessControl(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { + fakeStatsService := statstest.NewFakeService() + fakeStatsService.ExpectedAdminStats = &stats.AdminStats{} + fakeAnonService := anontest.NewFakeService() + fakeAnonService.ExpectedCountDevices = 0 server := SetupAPITestServer(t, func(hs *HTTPServer) { hs.Cfg = setting.NewCfg() hs.SQLStore = dbtest.NewFakeDB() hs.SettingsProvider = &setting.OSSImpl{Cfg: hs.Cfg} - hs.statsService = statstest.NewFakeService() + hs.statsService = fakeStatsService + hs.anonService = fakeAnonService }) res, err := server.Send(webtest.RequestWithSignedInUser(server.NewGetRequest(tt.url), userWithPermissions(1, tt.permissions))) diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index f712f8cadb5..194d2f20a6d 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -16,6 +16,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/grafana/grafana/pkg/services/anonymous" grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver" "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" @@ -206,6 +207,7 @@ type HTTPServer struct { promRegister prometheus.Registerer clientConfigProvider grafanaapiserver.DirectRestConfigProvider namespacer request.NamespaceMapper + anonService anonymous.Service } type ServerOptions struct { @@ -247,7 +249,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi accesscontrolService accesscontrol.Service, navTreeService navtree.Service, annotationRepo annotations.Repository, tagService tag.Service, searchv2HTTPService searchV2.SearchHTTPService, oauthTokenService oauthtoken.OAuthTokenService, statsService stats.Service, authnService authn.Service, pluginsCDNService *pluginscdn.Service, - starApi *starApi.API, promRegister prometheus.Registerer, clientConfigProvider grafanaapiserver.DirectRestConfigProvider, + starApi *starApi.API, promRegister prometheus.Registerer, clientConfigProvider grafanaapiserver.DirectRestConfigProvider, anonService anonymous.Service, ) (*HTTPServer, error) { web.Env = cfg.Env m := web.New() @@ -348,6 +350,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi promRegister: promRegister, clientConfigProvider: clientConfigProvider, namespacer: request.GetNamespaceMapper(cfg), + anonService: anonService, } if hs.Listener != nil { hs.log.Debug("Using provided listener") diff --git a/pkg/services/anonymous/anonimpl/anonstore/database.go b/pkg/services/anonymous/anonimpl/anonstore/database.go index a2a848cfb34..6531348662a 100644 --- a/pkg/services/anonymous/anonimpl/anonstore/database.go +++ b/pkg/services/anonymous/anonimpl/anonstore/database.go @@ -21,11 +21,11 @@ type AnonDBStore struct { type Device struct { ID int64 `json:"-" xorm:"id" db:"id"` - DeviceID string `json:"device_id" xorm:"device_id" db:"device_id"` - ClientIP string `json:"client_ip" xorm:"client_ip" db:"client_ip"` - UserAgent string `json:"user_agent" xorm:"user_agent" db:"user_agent"` - CreatedAt time.Time `json:"created_at" xorm:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" xorm:"updated_at" db:"updated_at"` + 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"` } func (a *Device) CacheKey() string { diff --git a/pkg/services/anonymous/anonimpl/api/api.go b/pkg/services/anonymous/anonimpl/api/api.go new file mode 100644 index 00000000000..85d9c7318e0 --- /dev/null +++ b/pkg/services/anonymous/anonimpl/api/api.go @@ -0,0 +1,98 @@ +package api + +import ( + "net/http" + "time" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/anonymous/anonimpl/anonstore" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" +) + +const ( + thirtyDays = 30 * 24 * time.Hour +) + +type deviceDTO struct { + anonstore.Device + LastSeenAt string `json:"lastSeenAt"` + AvatarUrl string `json:"avatarUrl"` +} + +type AnonDeviceServiceAPI struct { + cfg *setting.Cfg + store anonstore.AnonStore + accesscontrol accesscontrol.AccessControl + RouterRegister routing.RouteRegister + log log.Logger +} + +func NewAnonDeviceServiceAPI( + cfg *setting.Cfg, + anonstore anonstore.AnonStore, + accesscontrol accesscontrol.AccessControl, + routerRegister routing.RouteRegister, +) *AnonDeviceServiceAPI { + return &AnonDeviceServiceAPI{ + cfg: cfg, + store: anonstore, + accesscontrol: accesscontrol, + RouterRegister: routerRegister, + log: log.New("anon.api"), + } +} + +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)) + }) +} + +// swagger:route GET /stats devices listDevices +// +// # Lists all devices within the last 30 days +// +// Produces: +// - application/json +// +// Responses: +// +// 200: devicesResponse +// 401: unauthorisedError +// 403: forbiddenError +// 404: notFoundError +// 500: internalServerError +func (api *AnonDeviceServiceAPI) ListDevices(c *contextmodel.ReqContext) response.Response { + fromTime := time.Now().Add(-thirtyDays) + toTime := time.Now() + + devices, err := api.store.ListDevices(c.Req.Context(), &fromTime, &toTime) + if err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to list devices", err) + } + + // convert to response format + resDevices := make([]*deviceDTO, 0, len(devices)) + for _, device := range devices { + resDevices = append(resDevices, &deviceDTO{ + Device: *device, + LastSeenAt: util.GetAgeString(device.UpdatedAt), + AvatarUrl: dtos.GetGravatarUrl(device.DeviceID), + }) + } + + return response.JSON(http.StatusOK, resDevices) +} + +// swagger:response devicesResponse +type DevicesResponse struct { + // in:body + Body []deviceDTO `json:"body"` +} diff --git a/pkg/services/anonymous/anonimpl/client_test.go b/pkg/services/anonymous/anonimpl/client_test.go index 229b6c7dbc5..7f3118d61a6 100644 --- a/pkg/services/anonymous/anonimpl/client_test.go +++ b/pkg/services/anonymous/anonimpl/client_test.go @@ -49,7 +49,7 @@ func TestAnonymous_Authenticate(t *testing.T) { cfg: tt.cfg, log: log.NewNopLogger(), orgService: &orgtest.FakeOrgService{ExpectedOrg: tt.org, ExpectedError: tt.err}, - anonDeviceService: &anontest.FakeAnonymousSessionService{}, + anonDeviceService: anontest.NewFakeService(), } identity, err := c.Authenticate(context.Background(), &authn.Request{}) diff --git a/pkg/services/anonymous/anonimpl/impl.go b/pkg/services/anonymous/anonimpl/impl.go index fec95479aab..4a988054127 100644 --- a/pkg/services/anonymous/anonimpl/impl.go +++ b/pkg/services/anonymous/anonimpl/impl.go @@ -5,13 +5,16 @@ import ( "net/http" "time" + "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/network" "github.com/grafana/grafana/pkg/infra/serverlock" "github.com/grafana/grafana/pkg/infra/usagestats" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/anonymous" "github.com/grafana/grafana/pkg/services/anonymous/anonimpl/anonstore" + "github.com/grafana/grafana/pkg/services/anonymous/anonimpl/api" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/setting" @@ -31,7 +34,7 @@ type AnonDeviceService struct { func ProvideAnonymousDeviceService(usageStats usagestats.Service, authBroker authn.Service, anonStore anonstore.AnonStore, cfg *setting.Cfg, orgService org.Service, - serverLockService *serverlock.ServerLockService, + serverLockService *serverlock.ServerLockService, accesscontrol accesscontrol.AccessControl, routeRegister routing.RouteRegister, ) *AnonDeviceService { a := &AnonDeviceService{ log: log.New("anonymous-session-service"), @@ -54,6 +57,9 @@ func ProvideAnonymousDeviceService(usageStats usagestats.Service, authBroker aut authBroker.RegisterPostLoginHook(a.untagDevice, 100) } + anonAPI := api.NewAnonDeviceServiceAPI(cfg, anonStore, accesscontrol, routeRegister) + anonAPI.RegisterAPIEndpoints() + return a } @@ -142,6 +148,16 @@ func (a *AnonDeviceService) TagDevice(ctx context.Context, httpReq *http.Request return nil } +// 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) { + return a.anonStore.ListDevices(ctx, from, 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) { + return a.anonStore.CountDevices(ctx, from, to) +} + 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 a199aa46e9d..1a1ca2a89e7 100644 --- a/pkg/services/anonymous/anonimpl/impl_test.go +++ b/pkg/services/anonymous/anonimpl/impl_test.go @@ -9,8 +9,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/usagestats" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/anonymous" "github.com/grafana/grafana/pkg/services/anonymous/anonimpl/anonstore" "github.com/grafana/grafana/pkg/services/authn/authntest" @@ -113,7 +115,7 @@ func TestIntegrationDeviceService_tag(t *testing.T) { store := db.InitTestDB(t) anonDBStore := anonstore.ProvideAnonDBStore(store) anonService := ProvideAnonymousDeviceService(&usagestats.UsageStatsMock{}, - &authntest.FakeService{}, anonDBStore, setting.NewCfg(), orgtest.NewOrgServiceFake(), nil) + &authntest.FakeService{}, anonDBStore, setting.NewCfg(), orgtest.NewOrgServiceFake(), nil, actest.FakeAccessControl{}, &routing.RouteRegisterImpl{}) for _, req := range tc.req { err := anonService.TagDevice(context.Background(), req.httpReq, req.kind) @@ -149,7 +151,7 @@ func TestIntegrationAnonDeviceService_localCacheSafety(t *testing.T) { store := db.InitTestDB(t) anonDBStore := anonstore.ProvideAnonDBStore(store) anonService := ProvideAnonymousDeviceService(&usagestats.UsageStatsMock{}, - &authntest.FakeService{}, anonDBStore, setting.NewCfg(), orgtest.NewOrgServiceFake(), nil) + &authntest.FakeService{}, anonDBStore, setting.NewCfg(), orgtest.NewOrgServiceFake(), nil, actest.FakeAccessControl{}, &routing.RouteRegisterImpl{}) req := &http.Request{ Header: http.Header{ diff --git a/pkg/services/anonymous/anontest/fake.go b/pkg/services/anonymous/anontest/fake.go index 44b1fcca0ce..97b7fcdb840 100644 --- a/pkg/services/anonymous/anontest/fake.go +++ b/pkg/services/anonymous/anontest/fake.go @@ -3,13 +3,27 @@ package anontest import ( "context" "net/http" + "time" "github.com/grafana/grafana/pkg/services/anonymous" ) +type FakeService struct { + ExpectedCountDevices int64 + ExpectedError error +} + +func NewFakeService() *FakeService { + return &FakeService{} +} + type FakeAnonymousSessionService struct { } -func (f *FakeAnonymousSessionService) TagDevice(ctx context.Context, httpReq *http.Request, kind anonymous.DeviceKind) error { +func (f *FakeService) TagDevice(ctx context.Context, httpReq *http.Request, kind anonymous.DeviceKind) error { return nil } + +func (f *FakeService) CountDevices(ctx context.Context, from time.Time, to time.Time) (int64, error) { + return f.ExpectedCountDevices, nil +} diff --git a/pkg/services/anonymous/service.go b/pkg/services/anonymous/service.go index b303d705843..7716b11a6b3 100644 --- a/pkg/services/anonymous/service.go +++ b/pkg/services/anonymous/service.go @@ -3,6 +3,7 @@ package anonymous import ( "context" "net/http" + "time" ) type DeviceKind string @@ -13,4 +14,5 @@ const ( type Service interface { TagDevice(context.Context, *http.Request, DeviceKind) error + CountDevices(ctx context.Context, from time.Time, to time.Time) (int64, error) } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index de19ffc975b..3ef7142de07 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1082,6 +1082,13 @@ var ( FrontendOnly: true, Owner: grafanaBiSquad, }, + { + Name: "displayAnonymousStats", + Description: "Enables anonymous stats to be shown in the UI for Grafana", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: identityAccessTeam, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index e7ba5c895f0..0f96ef71e2f 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -146,3 +146,4 @@ alertingSimplifiedRouting,experimental,@grafana/alerting-squad,false,false,false logRowsPopoverMenu,experimental,@grafana/observability-logs,false,false,false,true pluginsSkipHostEnvVars,experimental,@grafana/plugins-platform-backend,false,false,false,false regressionTransformation,experimental,@grafana/grafana-bi-squad,false,false,false,true +displayAnonymousStats,experimental,@grafana/identity-access-team,false,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 5a663e65aa8..20958599d2f 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -594,4 +594,8 @@ const ( // FlagRegressionTransformation // Enables regression analysis transformation FlagRegressionTransformation = "regressionTransformation" + + // FlagDisplayAnonymousStats + // Enables anonymous stats to be shown in the UI for Grafana + FlagDisplayAnonymousStats = "displayAnonymousStats" ) diff --git a/pkg/services/stats/models.go b/pkg/services/stats/models.go index ea6ae9eecb3..447a07f95b5 100644 --- a/pkg/services/stats/models.go +++ b/pkg/services/stats/models.go @@ -77,6 +77,9 @@ type NotifierUsageStats struct { type GetAlertNotifierUsageStatsQuery struct{} +type AnonymousStats struct { + ActiveDevices int64 `json:"activeDevices"` +} type AdminStats struct { Orgs int64 `json:"orgs"` Dashboards int64 `json:"dashboards"` @@ -101,6 +104,7 @@ type AdminStats struct { DailyActiveViewers int64 `json:"dailyActiveViewers"` DailyActiveSessions int64 `json:"dailyActiveSessions"` MonthlyActiveUsers int64 `json:"monthlyActiveUsers"` + AnonymousStats } type GetAdminStatsQuery struct{} diff --git a/pkg/services/stats/statstest/stats.go b/pkg/services/stats/statstest/stats.go index 679a5d68788..89245d61251 100644 --- a/pkg/services/stats/statstest/stats.go +++ b/pkg/services/stats/statstest/stats.go @@ -7,6 +7,7 @@ import ( ) type FakeService struct { + ExpectedAdminStats *stats.AdminStats ExpectedSystemStats *stats.SystemStats ExpectedDataSourceStats []*stats.DataSourceStats ExpectedDataSourcesAccessStats []*stats.DataSourceAccessStats @@ -20,7 +21,7 @@ func NewFakeService() *FakeService { } func (s *FakeService) GetAdminStats(ctx context.Context, query *stats.GetAdminStatsQuery) (*stats.AdminStats, error) { - return nil, s.ExpectedError + return s.ExpectedAdminStats, s.ExpectedError } func (s *FakeService) GetAlertNotifiersUsageStats(ctx context.Context, query *stats.GetAlertNotifierUsageStatsQuery) ([]*stats.NotifierUsageStats, error) { diff --git a/public/api-merged.json b/public/api-merged.json index f04bce9f580..824983cabb7 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -9508,6 +9508,35 @@ } } }, + "/stats": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "devices" + ], + "summary": "Lists all devices within the last 30 days", + "operationId": "listDevices", + "responses": { + "200": { + "$ref": "#/responses/devicesResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, "/teams": { "post": { "tags": [ @@ -11150,6 +11179,10 @@ "type": "integer", "format": "int64" }, + "activeDevices": { + "type": "integer", + "format": "int64" + }, "activeEditors": { "type": "integer", "format": "int64" @@ -20537,6 +20570,34 @@ } } }, + "deviceDTO": { + "type": "object", + "properties": { + "avatarUrl": { + "type": "string" + }, + "clientIp": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "deviceId": { + "type": "string" + }, + "lastSeenAt": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "userAgent": { + "type": "string" + } + } + }, "gettableAlert": { "description": "GettableAlert gettable alert", "type": "object", @@ -21355,6 +21416,15 @@ } } }, + "devicesResponse": { + "description": "(empty)", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/deviceDTO" + } + } + }, "folderResponse": { "description": "(empty)", "schema": { diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index e5e664aad04..9bbf41c9fa9 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -16,7 +16,6 @@ import { import { v4 as uuidv4 } from 'uuid'; import { AppEvents, DataQueryErrorType } from '@grafana/data'; -import { GrafanaEdition } from '@grafana/data/src/types/config'; import { BackendSrv as BackendService, BackendSrvRequest, config, FetchError, FetchResponse } from '@grafana/runtime'; import appEvents from 'app/core/app_events'; import { getConfig } from 'app/core/config'; @@ -94,10 +93,6 @@ export class BackendSrv implements BackendService { } private async initGrafanaDeviceID() { - if (config.buildInfo?.edition === GrafanaEdition.OpenSource) { - return; - } - try { const fp = await FingerprintJS.load(); const result = await fp.get(); @@ -161,8 +156,7 @@ export class BackendSrv implements BackendService { } } - // Add device id header if not OSS build - if (config.buildInfo?.edition !== GrafanaEdition.OpenSource && this.deviceID) { + if (!!this.deviceID) { options.headers = options.headers ?? {}; options.headers['X-Grafana-Device-Id'] = `${this.deviceID}`; } diff --git a/public/app/features/admin/ServerStats.test.tsx b/public/app/features/admin/ServerStats.test.tsx index 3bae8e74e0b..9d26f6f264a 100644 --- a/public/app/features/admin/ServerStats.test.tsx +++ b/public/app/features/admin/ServerStats.test.tsx @@ -1,6 +1,8 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; +import config from 'app/core/config'; + import { ServerStats } from './ServerStats'; import { ServerStat } from './state/apis'; @@ -10,6 +12,7 @@ const stats: ServerStat = { activeSessions: 1, activeUsers: 1, activeViewers: 0, + activeDevices: 1, admins: 1, alerts: 5, dashboards: 1599, @@ -46,4 +49,12 @@ describe('ServerStats', () => { expect(screen.getByRole('link', { name: 'Alerts' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Manage users' })).toBeInTheDocument(); }); + + it('Should render page with anonymous stats', async () => { + config.featureToggles.displayAnonymousStats = true; + render(); + expect(await screen.findByRole('heading', { name: /instance statistics/i })).toBeInTheDocument(); + expect(screen.getByText('Active anonymous devices in last 30 days')).toBeInTheDocument(); + expect(screen.getByText('Active anonymous users in last 30 days')).toBeInTheDocument(); + }); }); diff --git a/public/app/features/admin/ServerStats.tsx b/public/app/features/admin/ServerStats.tsx index 116fc8e2f32..3d0f97d7cd7 100644 --- a/public/app/features/admin/ServerStats.tsx +++ b/public/app/features/admin/ServerStats.tsx @@ -2,6 +2,7 @@ import { css } from '@emotion/css'; import React, { useEffect, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { CardContainer, LinkButton, useStyles2 } from '@grafana/ui'; import { AccessControlAction } from 'app/types'; @@ -80,6 +81,12 @@ export const ServerStats = () => { { name: 'Organisations', value: stats.orgs }, { name: 'Users total', value: stats.users }, { name: 'Active users in last 30 days', value: stats.activeUsers }, + ...(config.featureToggles.displayAnonymousStats && stats.activeDevices + ? [ + { name: 'Active anonymous devices in last 30 days', value: stats.activeDevices }, + { name: 'Active anonymous users in last 30 days', value: Math.floor(stats.activeDevices / 3) }, + ] + : []), { name: 'Active sessions', value: stats.activeSessions }, ]} footer={ diff --git a/public/app/features/admin/UserListAnonymousPage.tsx b/public/app/features/admin/UserListAnonymousPage.tsx new file mode 100644 index 00000000000..a452fa5a90a --- /dev/null +++ b/public/app/features/admin/UserListAnonymousPage.tsx @@ -0,0 +1,47 @@ +import React, { useEffect } from 'react'; +import { connect, ConnectedProps } from 'react-redux'; + +import { Page } from 'app/core/components/Page/Page'; + +import { StoreState } from '../../types'; + +import { AnonUsersDevicesTable } from './Users/AnonUsersTable'; +import { fetchUsersAnonymousDevices } from './state/actions'; + +const mapDispatchToProps = { + fetchUsersAnonymousDevices, +}; + +const mapStateToProps = (state: StoreState) => ({ + devices: state.userListAnonymousDevices.devices, +}); + +const connector = connect(mapStateToProps, mapDispatchToProps); + +interface OwnProps {} + +type Props = OwnProps & ConnectedProps; + +const UserListAnonymousDevicesPageUnConnected = ({ devices, fetchUsersAnonymousDevices }: Props) => { + useEffect(() => { + fetchUsersAnonymousDevices(); + }, [fetchUsersAnonymousDevices]); + + return ( + + + + ); +}; + +export const UserListAnonymousDevicesPageContent = connector(UserListAnonymousDevicesPageUnConnected); + +export function UserListAnonymousDevicesPage() { + return ( + + + + ); +} + +export default UserListAnonymousDevicesPage; diff --git a/public/app/features/admin/UserListPage.tsx b/public/app/features/admin/UserListPage.tsx index 17bf9ab28bf..918a78c99d8 100644 --- a/public/app/features/admin/UserListPage.tsx +++ b/public/app/features/admin/UserListPage.tsx @@ -12,12 +12,14 @@ import { AccessControlAction } from '../../types'; import { UsersListPageContent } from '../users/UsersListPage'; import { UserListAdminPageContent } from './UserListAdminPage'; +import { UserListAnonymousDevicesPageContent } from './UserListAnonymousPage'; import { UserListPublicDashboardPage } from './UserListPublicDashboardPage/UserListPublicDashboardPage'; enum TabView { ADMIN = 'admin', ORG = 'org', PUBLIC_DASHBOARDS = 'public-dashboards', + ANON = 'anon', } const selectors = e2eSelectors.pages.UserListPage; @@ -35,6 +37,7 @@ const TAB_PAGE_MAP: Record = { [TabView.ADMIN]: , [TabView.ORG]: , [TabView.PUBLIC_DASHBOARDS]: , + [TabView.ANON]: , }; export default function UserListPage() { @@ -74,6 +77,14 @@ export default function UserListPage() { onChangeTab={() => setView(TabView.ORG)} data-testid={selectors.tabs.orgUsers} /> + {config.featureToggles.displayAnonymousStats && ( + setView(TabView.ANON)} + data-testid={selectors.tabs.anonUserDevices} + /> + )} {hasEmailSharingEnabled && } ) : ( diff --git a/public/app/features/admin/Users/AnonUsersTable.tsx b/public/app/features/admin/Users/AnonUsersTable.tsx new file mode 100644 index 00000000000..ba467f8a79f --- /dev/null +++ b/public/app/features/admin/Users/AnonUsersTable.tsx @@ -0,0 +1,82 @@ +import React, { useMemo } from 'react'; + +import { Avatar, CellProps, Column, InteractiveTable, Stack, Badge, Tooltip } from '@grafana/ui'; +import { UserAnonymousDeviceDTO } from 'app/types'; + +type Cell = CellProps< + UserAnonymousDeviceDTO, + UserAnonymousDeviceDTO[T] +>; + +// A helper function to parse the user agent string and extract parts +const parseUserAgent = (userAgent: string) => { + return { + browser: userAgent.split(' ')[0], + computer: userAgent.split(' ')[1], + }; +}; + +// A helper function to truncate each part of the user agent +const truncatePart = (part: string, maxLength: number) => { + return part.length > maxLength ? part.substring(0, maxLength) + '...' : part; +}; + +interface UserAgentCellProps { + value: string; +} + +const UserAgentCell = ({ value }: UserAgentCellProps) => { + const parts = parseUserAgent(value); + return ( + + + {truncatePart(parts.browser, 10)} + {truncatePart(parts.computer, 10)} + + + ); +}; + +interface AnonUsersTableProps { + devices: UserAnonymousDeviceDTO[]; +} + +export const AnonUsersDevicesTable = ({ devices }: AnonUsersTableProps) => { + const columns: Array> = useMemo( + () => [ + { + id: 'avatarUrl', + header: '', + cell: ({ cell: { value } }: Cell<'avatarUrl'>) => value && , + }, + { + id: 'login', + header: 'Login', + cell: ({ cell: { value } }: Cell<'login'>) => 'Anonymous', + }, + { + id: 'userAgent', + header: 'User Agent', + cell: ({ cell: { value } }: Cell<'userAgent'>) => , + sortType: 'string', + }, + { + id: 'lastSeenAt', + header: 'Last active', + cell: ({ cell: { value } }: Cell<'lastSeenAt'>) => value, + sortType: (a, b) => new Date(a.original.updatedAt).getTime() - new Date(b.original.updatedAt).getTime(), + }, + { + id: 'clientIp', + header: 'Origin IP (address)', + cell: ({ cell: { value } }: Cell<'clientIp'>) => value && , + }, + ], + [] + ); + return ( + + user.deviceId} /> + + ); +}; diff --git a/public/app/features/admin/state/actions.ts b/public/app/features/admin/state/actions.ts index f1daa23c723..eaca44d51cd 100644 --- a/public/app/features/admin/state/actions.ts +++ b/public/app/features/admin/state/actions.ts @@ -28,6 +28,7 @@ import { usersFetchBegin, usersFetchEnd, sortChanged, + usersAnonymousDevicesFetched, } from './reducers'; // UserAdminPage @@ -334,3 +335,18 @@ export function changeSort({ sortBy }: FetchDataArgs): 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/apis.tsx b/public/app/features/admin/state/apis.tsx index 79c2061e4f5..a573d827ed6 100644 --- a/public/app/features/admin/state/apis.tsx +++ b/public/app/features/admin/state/apis.tsx @@ -1,6 +1,10 @@ import { getBackendSrv } from '@grafana/runtime'; -export interface ServerStat { +interface AnonServerStat { + activeDevices?: number; +} + +export interface ServerStat extends AnonServerStat { activeAdmins: number; activeEditors: number; activeSessions: number; diff --git a/public/app/features/admin/state/reducers.ts b/public/app/features/admin/state/reducers.ts index f525c7612ba..68af1117cbf 100644 --- a/public/app/features/admin/state/reducers.ts +++ b/public/app/features/admin/state/reducers.ts @@ -13,6 +13,8 @@ import { UserSession, UserListAdminState, UserFilter, + UserListAnonymousDevicesState, + UserAnonymousDeviceDTO, } from 'app/types'; const initialLdapState: LdapState = { @@ -201,8 +203,37 @@ export const { usersFetched, usersFetchBegin, usersFetchEnd, queryChanged, pageC userListAdminSlice.actions; export const userListAdminReducer = userListAdminSlice.reducer; +// UserListAnonymousPage + +const initialUserListAnonymousDevicesState: UserListAnonymousDevicesState = { + devices: [], +}; + +interface UsersAnonymousDevicesFetched { + devices: UserAnonymousDeviceDTO[]; +} + +export const userListAnonymousDevicesSlice = createSlice({ + name: 'userListAnonymousDevices', + initialState: initialUserListAnonymousDevicesState, + reducers: { + usersAnonymousDevicesFetched: (state, action: PayloadAction) => { + const { devices } = action.payload; + return { + ...state, + devices, + isLoading: false, + }; + }, + }, +}); + +export const { usersAnonymousDevicesFetched } = userListAnonymousDevicesSlice.actions; +export const userListAnonymousDevicesReducer = userListAnonymousDevicesSlice.reducer; + export default { ldap: ldapReducer, userAdmin: userAdminReducer, userListAdmin: userListAdminReducer, + userListAnonymousDevices: userListAnonymousDevicesReducer, }; diff --git a/public/app/types/user.ts b/public/app/types/user.ts index fcbf37ec4ee..1c300befa1a 100644 --- a/public/app/types/user.ts +++ b/public/app/types/user.ts @@ -131,3 +131,17 @@ export interface UserListAdminState { isLoading: boolean; sort?: string; } + +export interface UserAnonymousDeviceDTO { + login?: string; + clientIp: string; + deviceId: string; + userAgent: string; + updatedAt: string; + lastSeenAt: string; + avatarUrl?: string; +} + +export interface UserListAnonymousDevicesState { + devices: UserAnonymousDeviceDTO[]; +} diff --git a/public/openapi3.json b/public/openapi3.json index fb56d6a4f5c..dd5ada2376a 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -506,6 +506,19 @@ }, "description": "(empty)" }, + "devicesResponse": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/deviceDTO" + }, + "type": "array" + } + } + }, + "description": "(empty)" + }, "folderResponse": { "content": { "application/json": { @@ -2173,6 +2186,10 @@ "format": "int64", "type": "integer" }, + "activeDevices": { + "format": "int64", + "type": "integer" + }, "activeEditors": { "format": "int64", "type": "integer" @@ -11559,6 +11576,34 @@ ], "type": "object" }, + "deviceDTO": { + "properties": { + "avatarUrl": { + "type": "string" + }, + "clientIp": { + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "deviceId": { + "type": "string" + }, + "lastSeenAt": { + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + }, + "userAgent": { + "type": "string" + } + }, + "type": "object" + }, "gettableAlert": { "description": "GettableAlert gettable alert", "properties": { @@ -22347,6 +22392,32 @@ ] } }, + "/stats": { + "get": { + "operationId": "listDevices", + "responses": { + "200": { + "$ref": "#/components/responses/devicesResponse" + }, + "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" + ] + } + }, "/teams": { "post": { "operationId": "createTeam",