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 c4fe089419b..38f29ae5d0c 100644
--- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
+++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
@@ -151,6 +151,7 @@ Experimental features might be changed or removed without prior notice.
| `awsDatasourcesNewFormStyling` | Applies new form styling for configuration and query editors in AWS plugins |
| `cachingOptimizeSerializationMemoryUsage` | If enabled, the caching backend gradually serializes query responses for the cache, comparing against the configured `[caching]max_value_mb` value as it goes. This can can help prevent Grafana from running out of memory while attempting to cache very large query responses. |
| `pluginsInstrumentationStatusSource` | Include a status source label for plugin request metrics and logs |
+| `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 7e418662eec..873f397e873 100644
--- a/packages/grafana-data/src/types/featureToggles.gen.ts
+++ b/packages/grafana-data/src/types/featureToggles.gen.ts
@@ -146,4 +146,5 @@ export interface FeatureToggles {
panelTitleSearchInV1?: boolean;
pluginsInstrumentationStatusSource?: boolean;
panelFilterVariable?: boolean;
+ displayAnonymousStats?: boolean;
}
diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts
index 80c5a8c289a..17c296307d1 100644
--- a/packages/grafana-e2e-selectors/src/selectors/pages.ts
+++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts
@@ -307,6 +307,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 4f3a9c04ef5..5eb5507b21d 100644
--- a/pkg/api/http_server.go
+++ b/pkg/api/http_server.go
@@ -16,6 +16,8 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
+ "github.com/grafana/grafana/pkg/services/anonymous"
+
"github.com/grafana/grafana/pkg/api/avatar"
"github.com/grafana/grafana/pkg/api/routing"
httpstatic "github.com/grafana/grafana/pkg/api/static"
@@ -205,6 +207,7 @@ type HTTPServer struct {
authnService authn.Service
starApi *starApi.API
promRegister prometheus.Registerer
+ anonService anonymous.Service
}
type ServerOptions struct {
@@ -246,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,
+ starApi *starApi.API, promRegister prometheus.Registerer, anonService anonymous.Service,
) (*HTTPServer, error) {
web.Env = cfg.Env
@@ -348,6 +351,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
pluginsCDNService: pluginsCDNService,
starApi: starApi,
promRegister: promRegister,
+ 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 c3d8d20fa35..1661c6620cd 100644
--- a/pkg/services/featuremgmt/registry.go
+++ b/pkg/services/featuremgmt/registry.go
@@ -898,5 +898,12 @@ var (
Owner: grafanaDashboardsSquad,
HideFromDocs: true,
},
+ {
+ Name: "displayAnonymousStats",
+ Description: "Enables anonymous stats to be shown in the UI for Grafana",
+ Stage: FeatureStageExperimental,
+ FrontendOnly: true,
+ Owner: grafanaAuthnzSquad,
+ },
}
)
diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv
index 69acf305b29..e093ef942dd 100644
--- a/pkg/services/featuremgmt/toggles_gen.csv
+++ b/pkg/services/featuremgmt/toggles_gen.csv
@@ -127,3 +127,4 @@ cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-e
panelTitleSearchInV1,experimental,@grafana/backend-platform,true,false,false,false
pluginsInstrumentationStatusSource,experimental,@grafana/plugins-platform-backend,false,false,false,false
panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,false,true
+displayAnonymousStats,experimental,@grafana/grafana-authnz-team,false,false,false,true
diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go
index 167914b7508..d1b9a16e281 100644
--- a/pkg/services/featuremgmt/toggles_gen.go
+++ b/pkg/services/featuremgmt/toggles_gen.go
@@ -518,4 +518,8 @@ const (
// FlagPanelFilterVariable
// Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard
FlagPanelFilterVariable = "panelFilterVariable"
+
+ // 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 e4f369b625c..3623cb2c675 100644
--- a/public/api-merged.json
+++ b/public/api-merged.json
@@ -9402,6 +9402,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": [
@@ -11074,6 +11103,10 @@
"type": "integer",
"format": "int64"
},
+ "activeDevices": {
+ "type": "integer",
+ "format": "int64"
+ },
"activeEditors": {
"type": "integer",
"format": "int64"
@@ -19845,6 +19878,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",
@@ -20639,6 +20700,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 ba4951b8d85..e4a6a651127 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..9fd772b8343
--- /dev/null
+++ b/public/app/features/admin/Users/AnonUsersTable.tsx
@@ -0,0 +1,83 @@
+import React, { useMemo } from 'react';
+
+import { Stack } from '@grafana/experimental';
+import { Avatar, CellProps, Column, InteractiveTable, 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 2b10c35dbdc..44a2b09788d 100644
--- a/public/openapi3.json
+++ b/public/openapi3.json
@@ -505,6 +505,19 @@
},
"description": "(empty)"
},
+ "devicesResponse": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "items": {
+ "$ref": "#/components/schemas/deviceDTO"
+ },
+ "type": "array"
+ }
+ }
+ },
+ "description": "(empty)"
+ },
"folderResponse": {
"content": {
"application/json": {
@@ -2100,6 +2113,10 @@
"format": "int64",
"type": "integer"
},
+ "activeDevices": {
+ "format": "int64",
+ "type": "integer"
+ },
"activeEditors": {
"format": "int64",
"type": "integer"
@@ -10870,6 +10887,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": {
@@ -21520,6 +21565,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",