fix(monitor): scope groups by user access

This commit is contained in:
wucm667
2026-08-30 09:28:40 +08:00
parent b5827cfd54
commit a3bbf33c05
6 changed files with 338 additions and 34 deletions
+1 -1
View File
@@ -178,7 +178,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService)
channelMonitorV2Repository := repository.NewChannelMonitorV2Repository(db)
channelMonitorV2Service := service.ProvideChannelMonitorV2Service(channelMonitorV2Repository, settingService)
channelMonitorV2Handler := handler.NewChannelMonitorV2Handler(channelMonitorV2Service)
channelMonitorV2Handler := handler.NewChannelMonitorV2Handler(channelMonitorV2Service, apiKeyService)
dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db)
dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig)
dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig)
@@ -1,6 +1,7 @@
package handler
import (
"context"
"errors"
"net/http"
"strconv"
@@ -13,11 +14,16 @@ import (
)
type ChannelMonitorV2Handler struct {
service *service.ChannelMonitorV2Service
service *service.ChannelMonitorV2Service
apiKeyService channelMonitorV2GroupAuthorizer
}
func NewChannelMonitorV2Handler(svc *service.ChannelMonitorV2Service) *ChannelMonitorV2Handler {
return &ChannelMonitorV2Handler{service: svc}
type channelMonitorV2GroupAuthorizer interface {
GetAvailableGroups(ctx context.Context, userID int64) ([]service.Group, error)
}
func NewChannelMonitorV2Handler(svc *service.ChannelMonitorV2Service, apiKeyService *service.APIKeyService) *ChannelMonitorV2Handler {
return &ChannelMonitorV2Handler{service: svc, apiKeyService: apiKeyService}
}
// channelMonitorV2IsAdmin is true when the request already passed admin auth
@@ -68,13 +74,17 @@ func (h *ChannelMonitorV2Handler) Dimensions(c *gin.Context) {
if !ok {
return
}
admin := channelMonitorV2IsAdmin(c)
if !h.scopeFilter(c, &filter, admin) {
return
}
result, err := h.service.Dimensions(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
return
}
// Admin and user share this handler; only non-admin responses strip volume.
if !channelMonitorV2IsAdmin(c) {
if !admin {
service.RedactChannelMonitorV2Dimensions(result)
}
response.Success(c, result)
@@ -94,6 +104,9 @@ func (h *ChannelMonitorV2Handler) snapshot(c *gin.Context, admin bool) {
if !ok {
return
}
if !h.scopeFilter(c, &filter, admin) {
return
}
result, err := h.service.Snapshot(c.Request.Context(), filter, admin)
if err != nil {
response.ErrorFrom(c, err)
@@ -107,6 +120,9 @@ func (h *ChannelMonitorV2Handler) models(c *gin.Context, admin bool) {
if !ok {
return
}
if !h.scopeFilter(c, &filter, admin) {
return
}
result, err := h.service.Models(c.Request.Context(), filter, admin)
if err != nil {
response.ErrorFrom(c, err)
@@ -125,6 +141,9 @@ func (h *ChannelMonitorV2Handler) matrix(c *gin.Context, admin bool) {
response.BadRequest(c, err.Error())
return
}
if !h.scopeFilter(c, &filter, admin) {
return
}
result, err := h.service.Matrix(c.Request.Context(), filter, groupBy, admin)
if err != nil {
response.ErrorFrom(c, err)
@@ -138,7 +157,11 @@ func (h *ChannelMonitorV2Handler) Errors(c *gin.Context) {
if !ok {
return
}
result, err := h.service.ErrorsForViewer(c.Request.Context(), filter, channelMonitorV2IsAdmin(c))
admin := channelMonitorV2IsAdmin(c)
if !h.scopeFilter(c, &filter, admin) {
return
}
result, err := h.service.ErrorsForViewer(c.Request.Context(), filter, admin)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -156,6 +179,9 @@ func (h *ChannelMonitorV2Handler) users(c *gin.Context, admin bool) {
response.Error(c, http.StatusUnauthorized, "user not found in context")
return
}
if !h.scopeFilter(c, &filter, admin) {
return
}
result, err := h.service.Users(c.Request.Context(), filter, subject.UserID, admin)
if err != nil {
response.ErrorFrom(c, err)
@@ -164,6 +190,32 @@ func (h *ChannelMonitorV2Handler) users(c *gin.Context, admin bool) {
response.Success(c, result)
}
func (h *ChannelMonitorV2Handler) scopeFilter(c *gin.Context, filter *service.ChannelMonitorV2Filter, admin bool) bool {
if admin {
return true
}
if h.apiKeyService == nil {
response.Error(c, http.StatusInternalServerError, "channel monitor group authorization unavailable")
return false
}
subject, ok := middleware.GetAuthSubjectFromContext(c)
if !ok || subject.UserID <= 0 {
response.Unauthorized(c, "user not found in context")
return false
}
groups, err := h.apiKeyService.GetAvailableGroups(c.Request.Context(), subject.UserID)
if err != nil {
response.ErrorFrom(c, err)
return false
}
filter.RestrictGroups = true
filter.AllowedGroupIDs = make([]int64, 0, len(groups))
for i := range groups {
filter.AllowedGroupIDs = append(filter.AllowedGroupIDs, groups[i].ID)
}
return true
}
func (h *ChannelMonitorV2Handler) parseFilter(c *gin.Context) (service.ChannelMonitorV2Filter, bool) {
groups, err := parseChannelMonitorV2GroupIDs(queryList(c, "group_id"))
if err != nil {
@@ -1,15 +1,28 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type channelMonitorV2GroupAuthorizerStub struct {
groups []service.Group
err error
calls []int64
}
func (s *channelMonitorV2GroupAuthorizerStub) GetAvailableGroups(_ context.Context, userID int64) ([]service.Group, error) {
s.calls = append(s.calls, userID)
return s.groups, s.err
}
func TestChannelMonitorV2QueryListSupportsRepeatedAndCommaValues(t *testing.T) {
c, _ := gin.CreateTestContext(nil)
c.Request = httptest.NewRequest("GET", "/?platform=openai,grok&platform=anthropic", nil)
@@ -29,7 +42,46 @@ func TestChannelMonitorV2MatrixHandlerRejectsInvalidGroupBy(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/channel-monitor-v2/matrix?group_by=invalid", nil)
h := NewChannelMonitorV2Handler(service.NewChannelMonitorV2Service(nil))
h := NewChannelMonitorV2Handler(service.NewChannelMonitorV2Service(nil), nil)
h.Matrix(c)
require.Equal(t, http.StatusBadRequest, recorder.Code)
}
func TestChannelMonitorV2ScopeFilterUsesAvailableGroupsForOrdinaryUser(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/channel-monitor-v2/snapshot", nil)
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 42})
authorizer := &channelMonitorV2GroupAuthorizerStub{groups: []service.Group{{ID: 3}, {ID: 7}}}
h := &ChannelMonitorV2Handler{apiKeyService: authorizer}
filter := service.ChannelMonitorV2Filter{GroupIDs: []int64{7, 9}}
require.True(t, h.scopeFilter(c, &filter, false))
require.True(t, filter.RestrictGroups)
require.Equal(t, []int64{3, 7}, filter.AllowedGroupIDs)
require.Equal(t, []int64{42}, authorizer.calls)
}
func TestChannelMonitorV2ScopeFilterPreservesEmptyOrdinaryUserScope(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/channel-monitor-v2/snapshot", nil)
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 42})
h := &ChannelMonitorV2Handler{apiKeyService: &channelMonitorV2GroupAuthorizerStub{}}
filter := service.ChannelMonitorV2Filter{}
require.True(t, h.scopeFilter(c, &filter, false))
require.True(t, filter.RestrictGroups)
require.Empty(t, filter.AllowedGroupIDs)
}
func TestChannelMonitorV2ScopeFilterLeavesAdminUnrestricted(t *testing.T) {
authorizer := &channelMonitorV2GroupAuthorizerStub{}
h := &ChannelMonitorV2Handler{apiKeyService: authorizer}
filter := service.ChannelMonitorV2Filter{GroupIDs: []int64{9}}
require.True(t, h.scopeFilter(nil, &filter, true))
require.False(t, filter.RestrictGroups)
require.Nil(t, filter.AllowedGroupIDs)
require.Empty(t, authorizer.calls)
}
@@ -115,6 +115,13 @@ type channelMonitorV2Histogram struct {
}
func (r *channelMonitorV2Repository) GetDimensions(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config) (*service.ChannelMonitorV2Dimensions, error) {
if channelMonitorV2RestrictedGroupScopeEmpty(filter, cfg) {
return &service.ChannelMonitorV2Dimensions{
Platforms: []service.ChannelMonitorV2Dimension{},
Groups: []service.ChannelMonitorV2GroupDimension{},
Models: []service.ChannelMonitorV2Dimension{},
}, nil
}
coverage, err := r.loadCoverage(ctx, filter)
if err != nil {
return nil, err
@@ -211,6 +218,9 @@ func (r *channelMonitorV2Repository) GetDimensions(ctx context.Context, filter s
}
func (r *channelMonitorV2Repository) GetSnapshot(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, admin bool) (*service.ChannelMonitorV2Snapshot, error) {
if channelMonitorV2RestrictedGroupScopeEmpty(filter, cfg) {
return &service.ChannelMonitorV2Snapshot{Trend: []service.ChannelMonitorV2TrendPoint{}}, nil
}
coverage, err := r.loadCoverage(ctx, filter)
if err != nil {
return nil, err
@@ -275,6 +285,9 @@ func (r *channelMonitorV2Repository) GetSnapshot(ctx context.Context, filter ser
}
func (r *channelMonitorV2Repository) GetModels(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, admin bool) (*service.ChannelMonitorV2List[service.ChannelMonitorV2ModelRow], error) {
if channelMonitorV2RestrictedGroupScopeEmpty(filter, cfg) {
return &service.ChannelMonitorV2List[service.ChannelMonitorV2ModelRow]{Items: []service.ChannelMonitorV2ModelRow{}}, nil
}
coverage, err := r.loadCoverage(ctx, filter)
if err != nil {
return nil, err
@@ -353,6 +366,9 @@ type channelMonitorV2MatrixAccumulator struct {
}
func (r *channelMonitorV2Repository) GetMatrix(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, groupBy service.ChannelMonitorV2GroupBy, admin bool) (*service.ChannelMonitorV2Matrix, error) {
if channelMonitorV2RestrictedGroupScopeEmpty(filter, cfg) {
return &service.ChannelMonitorV2Matrix{GroupBy: groupBy, Items: []service.ChannelMonitorV2MatrixRow{}}, nil
}
coverage, err := r.loadCoverage(ctx, filter)
if err != nil {
return nil, err
@@ -370,7 +386,7 @@ func (r *channelMonitorV2Repository) GetMatrix(ctx context.Context, filter servi
seedGroupIDs := configuredChannelMonitorV2GroupIDs(filter, cfg)
// Empty config group list means all groups — load active groups so matrix seed
// can materialize real platform/group rows (not bare platform placeholders).
if len(seedGroupIDs) == 0 &&
if len(seedGroupIDs) == 0 && !filter.RestrictGroups &&
(groupBy == service.ChannelMonitorV2GroupByPlatformGroup || groupBy == service.ChannelMonitorV2GroupByPlatformGroupModel) {
allIDs, loadErr := r.listActiveGroupIDs(ctx)
if loadErr != nil {
@@ -503,7 +519,7 @@ func seedChannelMonitorV2MatrixAccumulators(filter service.ChannelMonitorV2Filte
groupIDs := []int64{0}
if needsGroup {
groupIDs = configuredChannelMonitorV2GroupIDs(filter, cfg)
if len(groupIDs) == 0 {
if len(groupIDs) == 0 && !filter.RestrictGroups {
// Empty config group list means "all groups": use discovered active groups.
for id := range groupInfo {
groupIDs = append(groupIDs, id)
@@ -581,15 +597,44 @@ func channelMonitorV2CatalogFilter(filter service.ChannelMonitorV2Filter) servic
}
func configuredChannelMonitorV2GroupIDs(filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config) []int64 {
groups, empty := channelMonitorV2ScopedGroupIDs(filter, cfg)
if empty {
return []int64{}
}
return groups
}
func channelMonitorV2ScopedGroupIDs(filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config) ([]int64, bool) {
groups := append([]int64(nil), cfg.GroupIDs...)
if filter.RestrictGroups {
if len(groups) > 0 {
groups = intersectInt64(groups, filter.AllowedGroupIDs)
} else {
groups = append([]int64(nil), filter.AllowedGroupIDs...)
}
if len(groups) == 0 {
return nil, true
}
}
if len(filter.GroupIDs) > 0 {
if len(groups) > 0 {
groups = intersectInt64(groups, filter.GroupIDs)
} else {
groups = append([]int64(nil), filter.GroupIDs...)
}
if len(groups) == 0 {
return nil, true
}
}
return groups
return groups, false
}
func channelMonitorV2RestrictedGroupScopeEmpty(filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config) bool {
if !filter.RestrictGroups {
return false
}
_, empty := channelMonitorV2ScopedGroupIDs(filter, cfg)
return empty
}
type channelMonitorV2GroupInfo struct {
@@ -723,16 +768,7 @@ func (r *channelMonitorV2Repository) loadErrorDetails(ctx context.Context, filte
} else {
conditions = append(conditions, "FALSE")
}
groups := cfg.GroupIDs
groupScopeEmpty := false
if len(filter.GroupIDs) > 0 {
if len(groups) > 0 {
groups = intersectInt64(groups, filter.GroupIDs)
groupScopeEmpty = len(groups) == 0
} else {
groups = filter.GroupIDs
}
}
groups, groupScopeEmpty := channelMonitorV2ScopedGroupIDs(filter, cfg)
if groupScopeEmpty {
conditions = append(conditions, "FALSE")
} else if len(groups) > 0 {
@@ -1136,16 +1172,7 @@ func channelMonitorV2Where(filter service.ChannelMonitorV2Filter, cfg service.Ch
} else {
conditions = append(conditions, "FALSE")
}
groups := cfg.GroupIDs
groupScopeEmpty := false
if len(filter.GroupIDs) > 0 {
if len(groups) > 0 {
groups = intersectInt64(groups, filter.GroupIDs)
groupScopeEmpty = len(groups) == 0
} else {
groups = filter.GroupIDs
}
}
groups, groupScopeEmpty := channelMonitorV2ScopedGroupIDs(filter, cfg)
if groupScopeEmpty {
conditions = append(conditions, "FALSE")
} else if len(groups) > 0 {
@@ -1,11 +1,14 @@
package repository
import (
"context"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/lib/pq"
"github.com/stretchr/testify/require"
)
@@ -93,6 +96,171 @@ func TestChannelMonitorV2WhereRejectsGroupFilterOutsideConfiguredScope(t *testin
require.Len(t, args, 3)
}
func TestChannelMonitorV2WhereRestrictsOrdinaryViewerToAllowedConfiguredGroups(t *testing.T) {
filter := service.ChannelMonitorV2Filter{
Start: time.Unix(1, 0), End: time.Unix(2, 0),
GroupIDs: []int64{4, 9}, AllowedGroupIDs: []int64{3, 4}, RestrictGroups: true,
}
cfg := service.ChannelMonitorV2Config{
Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}},
GroupIDs: []int64{3, 4, 9},
}
where, args := channelMonitorV2Where(filter, cfg, "m")
require.Contains(t, where, "m.group_id = ANY($4)")
require.Equal(t, pq.Array([]int64{4}), args[3])
}
func TestChannelMonitorV2WhereRejectsOrdinaryViewerWithNoAllowedGroups(t *testing.T) {
filter := service.ChannelMonitorV2Filter{
Start: time.Unix(1, 0), End: time.Unix(2, 0),
GroupIDs: []int64{9}, RestrictGroups: true,
}
cfg := service.ChannelMonitorV2Config{
Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}},
GroupIDs: []int64{3, 9},
}
where, _ := channelMonitorV2Where(filter, cfg, "m")
require.Contains(t, where, "FALSE")
require.NotContains(t, where, "m.group_id = ANY")
}
func TestChannelMonitorV2CatalogKeepsViewerScopeWhileIgnoringPickerFilters(t *testing.T) {
filter := service.ChannelMonitorV2Filter{
Platforms: []string{"openai"}, GroupIDs: []int64{9}, Models: []string{"gpt-5"},
AllowedGroupIDs: []int64{3}, RestrictGroups: true,
}
catalog := channelMonitorV2CatalogFilter(filter)
require.Empty(t, catalog.Platforms)
require.Empty(t, catalog.GroupIDs)
require.Empty(t, catalog.Models)
require.True(t, catalog.RestrictGroups)
require.Equal(t, []int64{3}, catalog.AllowedGroupIDs)
}
func TestChannelMonitorV2AdminScopeRemainsGlobal(t *testing.T) {
filter := service.ChannelMonitorV2Filter{Start: time.Unix(1, 0), End: time.Unix(2, 0), GroupIDs: []int64{9}}
cfg := service.ChannelMonitorV2Config{
Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}},
GroupIDs: []int64{3, 9},
}
where, args := channelMonitorV2Where(filter, cfg, "m")
require.Contains(t, where, "m.group_id = ANY($4)")
require.Equal(t, pq.Array([]int64{9}), args[3])
}
func TestChannelMonitorV2MatrixDoesNotSeedGroupsForEmptyViewerScope(t *testing.T) {
filter := service.ChannelMonitorV2Filter{RestrictGroups: true}
cfg := service.ChannelMonitorV2Config{
Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}},
GroupIDs: []int64{3, 9},
}
accs := seedChannelMonitorV2MatrixAccumulators(filter, cfg, service.ChannelMonitorV2GroupByPlatformGroup, map[int64]channelMonitorV2GroupInfo{
3: {name: "private"}, 9: {name: "other-private"},
})
require.Empty(t, accs)
}
func TestChannelMonitorV2EmptyRestrictedScopeReturnsEmptyInventory(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
repo := &channelMonitorV2Repository{db: db}
filter := service.ChannelMonitorV2Filter{RestrictGroups: true}
cfg := service.ChannelMonitorV2Config{
Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true, Models: []string{"gpt-5"}}},
GroupIDs: []int64{3, 9},
}
dimensions, err := repo.GetDimensions(context.Background(), filter, cfg)
require.NoError(t, err)
require.Empty(t, dimensions.Platforms)
require.Empty(t, dimensions.Groups)
require.Empty(t, dimensions.Models)
require.NotNil(t, dimensions.Platforms)
require.NotNil(t, dimensions.Groups)
require.NotNil(t, dimensions.Models)
models, err := repo.GetModels(context.Background(), filter, cfg, false)
require.NoError(t, err)
require.Empty(t, models.Items)
require.NotNil(t, models.Items)
require.Equal(t, service.ChannelMonitorV2Coverage{}, models.Coverage)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestChannelMonitorV2EmptyRestrictedScopeReturnsEmptySnapshotWithoutQueries(t *testing.T) {
tests := []struct {
name string
filter service.ChannelMonitorV2Filter
}{
{
name: "no allowed groups",
filter: service.ChannelMonitorV2Filter{RestrictGroups: true},
},
{
name: "requested groups exclude allowed configured scope",
filter: service.ChannelMonitorV2Filter{
GroupIDs: []int64{9}, AllowedGroupIDs: []int64{3}, RestrictGroups: true,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
repo := &channelMonitorV2Repository{db: db}
cfg := service.ChannelMonitorV2Config{
Enabled: true,
Platforms: []service.ChannelMonitorV2PlatformConfig{{
Platform: "openai", Enabled: true, Models: []string{"gpt-5"},
}},
GroupIDs: []int64{3},
}
snapshot, err := repo.GetSnapshot(context.Background(), test.filter, cfg, false)
require.NoError(t, err)
require.Equal(t, service.ChannelMonitorV2Config{}, snapshot.Config)
require.Equal(t, service.ChannelMonitorV2Coverage{}, snapshot.Coverage)
require.Equal(t, service.ChannelMonitorV2Metric{}, snapshot.Metrics)
require.Equal(t, service.ChannelMonitorV2Health{}, snapshot.Health)
require.Empty(t, snapshot.Trend)
require.NotNil(t, snapshot.Trend)
require.NoError(t, mock.ExpectationsWereMet())
})
}
}
func TestChannelMonitorV2EmptyRestrictedScopeReturnsEmptyMatrixForEveryGrouping(t *testing.T) {
groupings := []service.ChannelMonitorV2GroupBy{
service.ChannelMonitorV2GroupByPlatform,
service.ChannelMonitorV2GroupByPlatformModel,
service.ChannelMonitorV2GroupByPlatformGroup,
service.ChannelMonitorV2GroupByPlatformGroupModel,
}
for _, groupBy := range groupings {
t.Run(string(groupBy), func(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
repo := &channelMonitorV2Repository{db: db}
filter := service.ChannelMonitorV2Filter{RestrictGroups: true}
cfg := service.ChannelMonitorV2Config{
Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true, Models: []string{"gpt-5"}}},
GroupIDs: []int64{3, 9},
}
matrix, err := repo.GetMatrix(context.Background(), filter, cfg, groupBy, false)
require.NoError(t, err)
require.Equal(t, groupBy, matrix.GroupBy)
require.Empty(t, matrix.Items)
require.NotNil(t, matrix.Items)
require.Equal(t, service.ChannelMonitorV2Coverage{}, matrix.Coverage)
require.NoError(t, mock.ExpectationsWereMet())
})
}
}
func TestChannelMonitorV2ErrorAggregationCountsFinalUserErrorsOnly(t *testing.T) {
query := strings.ToLower(channelMonitorV2ErrorAggregationSQL)
require.Contains(t, query, "not current_error.is_count_tokens")
@@ -77,10 +77,15 @@ type ChannelMonitorV2Filter struct {
Range string
Platforms []string
GroupIDs []int64
Models []string
Start time.Time
End time.Time
Bucket time.Duration
// AllowedGroupIDs is the authenticated viewer's server-derived group scope.
// RestrictGroups distinguishes an ordinary user with no allowed groups from
// the unrestricted admin/configured scope represented by an empty slice.
AllowedGroupIDs []int64
RestrictGroups bool
Models []string
Start time.Time
End time.Time
Bucket time.Duration
}
type ChannelMonitorV2Metric struct {