mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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 <Clarity-89@users.noreply.github.com> * formatin * precise anon devices to avoid confusion --------- Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com> Co-authored-by: jguer <me@jguer.space>
This commit is contained in:
@@ -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
|
||||
|
||||
|
@@ -165,4 +165,5 @@ export interface FeatureToggles {
|
||||
logRowsPopoverMenu?: boolean;
|
||||
pluginsSkipHostEnvVars?: boolean;
|
||||
regressionTransformation?: boolean;
|
||||
displayAnonymousStats?: boolean;
|
||||
}
|
||||
|
@@ -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',
|
||||
},
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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)))
|
||||
|
@@ -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")
|
||||
|
@@ -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 {
|
||||
|
98
pkg/services/anonymous/anonimpl/api/api.go
Normal file
98
pkg/services/anonymous/anonimpl/api/api.go
Normal file
@@ -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"`
|
||||
}
|
@@ -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{})
|
||||
|
@@ -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)
|
||||
|
||||
|
@@ -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{
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
@@ -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
|
||||
|
|
@@ -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"
|
||||
)
|
||||
|
@@ -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{}
|
||||
|
@@ -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) {
|
||||
|
@@ -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": {
|
||||
|
@@ -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}`;
|
||||
}
|
||||
|
@@ -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(<ServerStats />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
@@ -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={
|
||||
|
47
public/app/features/admin/UserListAnonymousPage.tsx
Normal file
47
public/app/features/admin/UserListAnonymousPage.tsx
Normal file
@@ -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<typeof connector>;
|
||||
|
||||
const UserListAnonymousDevicesPageUnConnected = ({ devices, fetchUsersAnonymousDevices }: Props) => {
|
||||
useEffect(() => {
|
||||
fetchUsersAnonymousDevices();
|
||||
}, [fetchUsersAnonymousDevices]);
|
||||
|
||||
return (
|
||||
<Page.Contents>
|
||||
<AnonUsersDevicesTable devices={devices} />
|
||||
</Page.Contents>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserListAnonymousDevicesPageContent = connector(UserListAnonymousDevicesPageUnConnected);
|
||||
|
||||
export function UserListAnonymousDevicesPage() {
|
||||
return (
|
||||
<Page navId="anonymous-users">
|
||||
<UserListAnonymousDevicesPageContent />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserListAnonymousDevicesPage;
|
@@ -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, React.ReactElement> = {
|
||||
[TabView.ADMIN]: <UserListAdminPageContent />,
|
||||
[TabView.ORG]: <UsersListPageContent />,
|
||||
[TabView.PUBLIC_DASHBOARDS]: <UserListPublicDashboardPage />,
|
||||
[TabView.ANON]: <UserListAnonymousDevicesPageContent />,
|
||||
};
|
||||
|
||||
export default function UserListPage() {
|
||||
@@ -74,6 +77,14 @@ export default function UserListPage() {
|
||||
onChangeTab={() => setView(TabView.ORG)}
|
||||
data-testid={selectors.tabs.orgUsers}
|
||||
/>
|
||||
{config.featureToggles.displayAnonymousStats && (
|
||||
<Tab
|
||||
label="Anonymous devices"
|
||||
active={view === TabView.ANON}
|
||||
onChangeTab={() => setView(TabView.ANON)}
|
||||
data-testid={selectors.tabs.anonUserDevices}
|
||||
/>
|
||||
)}
|
||||
{hasEmailSharingEnabled && <PublicDashboardsTab view={view} setView={setView} />}
|
||||
</TabsBar>
|
||||
) : (
|
||||
|
82
public/app/features/admin/Users/AnonUsersTable.tsx
Normal file
82
public/app/features/admin/Users/AnonUsersTable.tsx
Normal file
@@ -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<T extends keyof UserAnonymousDeviceDTO = keyof UserAnonymousDeviceDTO> = 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 (
|
||||
<Tooltip theme="info-alt" content={value} placement="top-end" interactive={true}>
|
||||
<span>
|
||||
{truncatePart(parts.browser, 10)}
|
||||
{truncatePart(parts.computer, 10)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
interface AnonUsersTableProps {
|
||||
devices: UserAnonymousDeviceDTO[];
|
||||
}
|
||||
|
||||
export const AnonUsersDevicesTable = ({ devices }: AnonUsersTableProps) => {
|
||||
const columns: Array<Column<UserAnonymousDeviceDTO>> = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'avatarUrl',
|
||||
header: '',
|
||||
cell: ({ cell: { value } }: Cell<'avatarUrl'>) => value && <Avatar src={value} alt={'User avatar'} />,
|
||||
},
|
||||
{
|
||||
id: 'login',
|
||||
header: 'Login',
|
||||
cell: ({ cell: { value } }: Cell<'login'>) => 'Anonymous',
|
||||
},
|
||||
{
|
||||
id: 'userAgent',
|
||||
header: 'User Agent',
|
||||
cell: ({ cell: { value } }: Cell<'userAgent'>) => <UserAgentCell value={value} />,
|
||||
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 && <Badge text={value} color="orange" />,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
return (
|
||||
<Stack direction={'column'} gap={2}>
|
||||
<InteractiveTable columns={columns} data={devices} getRowId={(user) => user.deviceId} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
@@ -28,6 +28,7 @@ import {
|
||||
usersFetchBegin,
|
||||
usersFetchEnd,
|
||||
sortChanged,
|
||||
usersAnonymousDevicesFetched,
|
||||
} from './reducers';
|
||||
// UserAdminPage
|
||||
|
||||
@@ -334,3 +335,18 @@ export function changeSort({ sortBy }: FetchDataArgs<UserDTO>): ThunkResult<void
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// UserListAnonymousPage
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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<UsersAnonymousDevicesFetched>) => {
|
||||
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,
|
||||
};
|
||||
|
@@ -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[];
|
||||
}
|
||||
|
@@ -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",
|
||||
|
Reference in New Issue
Block a user