mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
Metrics: Add file system and account usage info #5355
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -14,12 +14,25 @@ import (
|
|||||||
|
|
||||||
"github.com/photoprism/photoprism/internal/auth/acl"
|
"github.com/photoprism/photoprism/internal/auth/acl"
|
||||||
"github.com/photoprism/photoprism/internal/config"
|
"github.com/photoprism/photoprism/internal/config"
|
||||||
"github.com/photoprism/photoprism/internal/entity/query"
|
|
||||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||||
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
|
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
|
||||||
"github.com/photoprism/photoprism/pkg/http/header"
|
"github.com/photoprism/photoprism/pkg/http/header"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
metricsNamespace = "photoprism"
|
||||||
|
metricsUsageSubsystem = "usage"
|
||||||
|
metricsLabelState = "state"
|
||||||
|
metricFilesBytes = "files_bytes"
|
||||||
|
metricFilesRatio = "files_ratio"
|
||||||
|
metricAccountsRatio = "accounts_ratio"
|
||||||
|
metricAccountsActive = "accounts_active"
|
||||||
|
metricsAccountsHelp = "active user and guest accounts on this PhotoPrism instance"
|
||||||
|
metricsFilesBytesHelp = "filesystem usage in bytes for files indexed by this PhotoPrism instance"
|
||||||
|
metricsFilesRatioHelp = "filesystem usage for files indexed by this PhotoPrism instance"
|
||||||
|
metricsAccountsRatioHelp = "account quota usage for this PhotoPrism instance"
|
||||||
|
)
|
||||||
|
|
||||||
// GetMetrics provides a Prometheus-compatible metrics endpoint for monitoring the instance, including usage details and portal cluster metrics.
|
// GetMetrics provides a Prometheus-compatible metrics endpoint for monitoring the instance, including usage details and portal cluster metrics.
|
||||||
//
|
//
|
||||||
// @Summary a prometheus-compatible metrics endpoint for monitoring this instance
|
// @Summary a prometheus-compatible metrics endpoint for monitoring this instance
|
||||||
@@ -45,10 +58,10 @@ func GetMetrics(router *gin.RouterGroup) {
|
|||||||
c.Header(header.ContentType, header.ContentTypePrometheus)
|
c.Header(header.ContentType, header.ContentTypePrometheus)
|
||||||
|
|
||||||
c.Stream(func(w io.Writer) bool {
|
c.Stream(func(w io.Writer) bool {
|
||||||
reg := prometheus.NewRegistry()
|
registry := prometheus.NewRegistry()
|
||||||
reg.MustRegister(collectors.NewGoCollector())
|
registry.MustRegister(collectors.NewGoCollector())
|
||||||
|
|
||||||
factory := promauto.With(reg)
|
factory := promauto.With(registry)
|
||||||
|
|
||||||
registerCountMetrics(factory, counts)
|
registerCountMetrics(factory, counts)
|
||||||
registerBuildInfoMetric(factory, conf.ClientPublic())
|
registerBuildInfoMetric(factory, conf.ClientPublic())
|
||||||
@@ -58,7 +71,7 @@ func GetMetrics(router *gin.RouterGroup) {
|
|||||||
var metrics []*dto.MetricFamily
|
var metrics []*dto.MetricFamily
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
metrics, err = reg.Gather()
|
metrics, err = registry.Gather()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logErr("metrics", err)
|
logErr("metrics", err)
|
||||||
@@ -124,8 +137,6 @@ func registerCountMetrics(factory promauto.Factory, counts config.ClientCounts)
|
|||||||
{"places", counts.Places},
|
{"places", counts.Places},
|
||||||
{"labels", counts.Labels},
|
{"labels", counts.Labels},
|
||||||
{"label_max_photos", counts.LabelMaxPhotos},
|
{"label_max_photos", counts.LabelMaxPhotos},
|
||||||
{"users", query.CountUsers(true, true, nil, []string{"guest"})},
|
|
||||||
{"guests", query.CountUsers(true, true, []string{"guest"}, nil)},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, stat := range stats {
|
for _, stat := range stats {
|
||||||
@@ -149,43 +160,58 @@ func registerBuildInfoMetric(factory promauto.Factory, conf *config.ClientConfig
|
|||||||
}
|
}
|
||||||
|
|
||||||
// registerUsageMetrics registers filesystem and account usage metrics derived from the active configuration.
|
// registerUsageMetrics registers filesystem and account usage metrics derived from the active configuration.
|
||||||
|
// Ratios follow Prometheus best practices (0..1) instead of percentages.
|
||||||
func registerUsageMetrics(factory promauto.Factory, usage config.Usage) {
|
func registerUsageMetrics(factory promauto.Factory, usage config.Usage) {
|
||||||
filesBytes := factory.NewGaugeVec(
|
filesBytes := factory.NewGaugeVec(
|
||||||
prometheus.GaugeOpts{
|
prometheus.GaugeOpts{
|
||||||
Namespace: "photoprism",
|
Namespace: metricsNamespace,
|
||||||
Subsystem: "usage",
|
Subsystem: metricsUsageSubsystem,
|
||||||
Name: "files_bytes",
|
Name: metricFilesBytes,
|
||||||
Help: "filesystem usage in bytes for files indexed by this PhotoPrism instance",
|
Help: metricsFilesBytesHelp,
|
||||||
}, []string{"state"},
|
}, []string{metricsLabelState},
|
||||||
)
|
)
|
||||||
|
|
||||||
filesBytes.With(prometheus.Labels{"state": "used"}).Set(float64(usage.FilesUsed))
|
filesBytes.With(prometheus.Labels{metricsLabelState: "used"}).Set(float64(usage.FilesUsed))
|
||||||
filesBytes.With(prometheus.Labels{"state": "free"}).Set(float64(usage.FilesFree))
|
filesBytes.With(prometheus.Labels{metricsLabelState: "free"}).Set(float64(usage.FilesFree))
|
||||||
filesBytes.With(prometheus.Labels{"state": "total"}).Set(float64(usage.FilesTotal))
|
filesBytes.With(prometheus.Labels{metricsLabelState: "total"}).Set(float64(usage.FilesTotal))
|
||||||
|
|
||||||
filesPercent := factory.NewGaugeVec(
|
filesRatio := factory.NewGaugeVec(
|
||||||
prometheus.GaugeOpts{
|
prometheus.GaugeOpts{
|
||||||
Namespace: "photoprism",
|
Namespace: metricsNamespace,
|
||||||
Subsystem: "usage",
|
Subsystem: metricsUsageSubsystem,
|
||||||
Name: "files_percent",
|
Name: metricFilesRatio,
|
||||||
Help: "filesystem usage in percent for files indexed by this PhotoPrism instance",
|
Help: metricsFilesRatioHelp,
|
||||||
}, []string{"state"},
|
}, []string{metricsLabelState},
|
||||||
)
|
)
|
||||||
|
|
||||||
filesPercent.With(prometheus.Labels{"state": "used"}).Set(float64(usage.FilesUsedPct))
|
filesUsed := usage.FilesUsedRatio()
|
||||||
filesPercent.With(prometheus.Labels{"state": "free"}).Set(float64(usage.FilesFreePct))
|
filesRatio.With(prometheus.Labels{metricsLabelState: "used"}).Set(filesUsed)
|
||||||
|
filesRatio.With(prometheus.Labels{metricsLabelState: "free"}).Set(1 - filesUsed)
|
||||||
|
|
||||||
accountsPercent := factory.NewGaugeVec(
|
accountsActive := factory.NewGaugeVec(
|
||||||
prometheus.GaugeOpts{
|
prometheus.GaugeOpts{
|
||||||
Namespace: "photoprism",
|
Namespace: metricsNamespace,
|
||||||
Subsystem: "usage",
|
Subsystem: metricsUsageSubsystem,
|
||||||
Name: "accounts_percent",
|
Name: metricAccountsActive,
|
||||||
Help: "account quota usage in percent for this PhotoPrism instance",
|
Help: metricsAccountsHelp,
|
||||||
}, []string{"state"},
|
}, []string{metricsLabelState},
|
||||||
)
|
)
|
||||||
|
|
||||||
accountsPercent.With(prometheus.Labels{"state": "used"}).Set(float64(usage.UsersUsedPct))
|
accountsActive.With(prometheus.Labels{metricsLabelState: "users"}).Set(float64(usage.UsersActive))
|
||||||
accountsPercent.With(prometheus.Labels{"state": "free"}).Set(float64(usage.UsersFreePct))
|
accountsActive.With(prometheus.Labels{metricsLabelState: "guests"}).Set(float64(usage.GuestsActive))
|
||||||
|
|
||||||
|
accountsRatio := factory.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Namespace: metricsNamespace,
|
||||||
|
Subsystem: metricsUsageSubsystem,
|
||||||
|
Name: metricAccountsRatio,
|
||||||
|
Help: metricsAccountsRatioHelp,
|
||||||
|
}, []string{metricsLabelState},
|
||||||
|
)
|
||||||
|
|
||||||
|
accountsUsed := usage.UsersUsedRatio()
|
||||||
|
accountsRatio.With(prometheus.Labels{metricsLabelState: "used"}).Set(accountsUsed)
|
||||||
|
accountsRatio.With(prometheus.Labels{metricsLabelState: "free"}).Set(1 - accountsUsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerClusterMetrics exports cluster-specific metrics when running as a portal instance.
|
// registerClusterMetrics exports cluster-specific metrics when running as a portal instance.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -47,8 +48,6 @@ func TestGetMetrics(t *testing.T) {
|
|||||||
"people",
|
"people",
|
||||||
"labels",
|
"labels",
|
||||||
"label_max_photos",
|
"label_max_photos",
|
||||||
"users",
|
|
||||||
"guests",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, stat := range stats {
|
for _, stat := range stats {
|
||||||
@@ -87,10 +86,34 @@ func TestGetMetrics(t *testing.T) {
|
|||||||
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_files_bytes{state="used"} `+floatPattern), body)
|
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_files_bytes{state="used"} `+floatPattern), body)
|
||||||
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_files_bytes{state="free"} `+floatPattern), body)
|
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_files_bytes{state="free"} `+floatPattern), body)
|
||||||
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_files_bytes{state="total"} `+floatPattern), body)
|
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_files_bytes{state="total"} `+floatPattern), body)
|
||||||
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_files_percent{state="used"} `+floatPattern), body)
|
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_files_ratio{state="used"} `+floatPattern), body)
|
||||||
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_files_percent{state="free"} `+floatPattern), body)
|
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_files_ratio{state="free"} `+floatPattern), body)
|
||||||
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_accounts_percent{state="used"} `+floatPattern), body)
|
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_accounts_active{state="users"} `+floatPattern), body)
|
||||||
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_accounts_percent{state="free"} `+floatPattern), body)
|
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_accounts_active{state="guests"} `+floatPattern), body)
|
||||||
|
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_accounts_ratio{state="used"} `+floatPattern), body)
|
||||||
|
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_accounts_ratio{state="free"} `+floatPattern), body)
|
||||||
|
|
||||||
|
t.Run("RatiosSumToOne", func(t *testing.T) {
|
||||||
|
parseValue := func(pattern string) float64 {
|
||||||
|
m := regexp.MustCompile(pattern).FindStringSubmatch(body)
|
||||||
|
if len(m) != 2 {
|
||||||
|
t.Fatalf("metric not found: %s", pattern)
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(m[1], 64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse metric: %v", err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
filesUsed := parseValue(`photoprism_usage_files_ratio{state="used"} (` + floatPattern + `)`)
|
||||||
|
filesFree := parseValue(`photoprism_usage_files_ratio{state="free"} (` + floatPattern + `)`)
|
||||||
|
assert.InEpsilon(t, 1.0, filesUsed+filesFree, 0.01)
|
||||||
|
|
||||||
|
accountsUsed := parseValue(`photoprism_usage_accounts_ratio{state="used"} (` + floatPattern + `)`)
|
||||||
|
accountsFree := parseValue(`photoprism_usage_accounts_ratio{state="free"} (` + floatPattern + `)`)
|
||||||
|
assert.InEpsilon(t, 1.0, accountsUsed+accountsFree, 0.01)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
t.Run("ExposeClusterMetricsForPortal", func(t *testing.T) {
|
t.Run("ExposeClusterMetricsForPortal", func(t *testing.T) {
|
||||||
app, router, conf := NewApiTest()
|
app, router, conf := NewApiTest()
|
||||||
|
|||||||
@@ -20,14 +20,51 @@ func FlushUsageCache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Usage represents storage usage information.
|
// Usage represents storage usage information.
|
||||||
|
// Percent fields remain for UI/backward compatibility, but metrics should
|
||||||
|
// derive their values from the ratio helpers to align with Prometheus
|
||||||
|
// conventions and avoid rounding artifacts.
|
||||||
type Usage struct {
|
type Usage struct {
|
||||||
|
// File storage usage and quota (total).
|
||||||
FilesUsed uint64 `json:"filesUsed"`
|
FilesUsed uint64 `json:"filesUsed"`
|
||||||
FilesUsedPct int `json:"filesUsedPct"`
|
FilesUsedPct int `json:"filesUsedPct"`
|
||||||
FilesFree uint64 `json:"filesFree"`
|
FilesFree uint64 `json:"filesFree"`
|
||||||
FilesFreePct int `json:"filesFreePct"`
|
FilesFreePct int `json:"filesFreePct"`
|
||||||
FilesTotal uint64 `json:"filesTotal"`
|
FilesTotal uint64 `json:"filesTotal"`
|
||||||
UsersUsedPct int `json:"usersUsedPct"`
|
|
||||||
UsersFreePct int `json:"usersFreePct"`
|
// UsersQuota is the configured account quota; kept internal because the
|
||||||
|
// public client config should not expose capacity limits directly.
|
||||||
|
UsersQuota int `json:"-"`
|
||||||
|
// UsersActive and GuestsActive report how many registered accounts are
|
||||||
|
// currently enabled; kept internal so metrics can use them without leaking
|
||||||
|
// into client-visible JSON.
|
||||||
|
UsersActive int `json:"-"`
|
||||||
|
GuestsActive int `json:"-"`
|
||||||
|
UsersUsedPct int `json:"usersUsedPct"`
|
||||||
|
UsersFreePct int `json:"usersFreePct"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilesUsedRatio calculates the file storage usage ratio.
|
||||||
|
func (info *Usage) FilesUsedRatio() float64 {
|
||||||
|
if info.FilesUsed == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.FilesTotal == 0 {
|
||||||
|
// Return a tiny non-zero value to avoid emitting NaN in metrics when
|
||||||
|
// totals are unknown (e.g. quota disabled or filesystem probe failed).
|
||||||
|
return 0.01
|
||||||
|
}
|
||||||
|
|
||||||
|
return float64(info.FilesUsed) / float64(info.FilesTotal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsersUsedRatio calculates the user account usage ratio.
|
||||||
|
func (info *Usage) UsersUsedRatio() float64 {
|
||||||
|
if info.UsersActive == 0 || info.UsersQuota == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return float64(info.UsersActive) / float64(info.UsersQuota)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage returns the used, free and total storage size in bytes and caches the result.
|
// Usage returns the used, free and total storage size in bytes and caches the result.
|
||||||
@@ -67,7 +104,7 @@ func (c *Config) Usage() Usage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if info.FilesTotal > 0 {
|
if info.FilesTotal > 0 {
|
||||||
info.FilesUsedPct = int(math.RoundToEven(float64(info.FilesUsed) / float64(info.FilesTotal) * 100))
|
info.FilesUsedPct = int(math.RoundToEven(info.FilesUsedRatio() * 100))
|
||||||
}
|
}
|
||||||
|
|
||||||
if info.FilesUsed > 0 && info.FilesUsedPct <= 0 {
|
if info.FilesUsed > 0 && info.FilesUsedPct <= 0 {
|
||||||
@@ -80,14 +117,19 @@ func (c *Config) Usage() Usage {
|
|||||||
info.FilesFreePct = 0
|
info.FilesFreePct = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if usersTotal := c.UsersQuota(); usersTotal > 0 {
|
info.UsersActive = query.CountUsers(true, true, nil, []string{"guest"})
|
||||||
usersUsed := query.CountUsers(true, true, nil, []string{"guest"})
|
info.GuestsActive = query.CountUsers(true, true, []string{"guest"}, nil)
|
||||||
info.UsersUsedPct = int(math.Floor(float64(usersUsed) / float64(usersTotal) * 100))
|
|
||||||
|
if info.UsersQuota = c.UsersQuota(); info.UsersQuota > 0 {
|
||||||
|
info.UsersUsedPct = int(math.Floor(info.UsersUsedRatio() * 100))
|
||||||
info.UsersFreePct = 100 - info.UsersUsedPct
|
info.UsersFreePct = 100 - info.UsersUsedPct
|
||||||
|
|
||||||
if info.UsersFreePct < 0 {
|
if info.UsersFreePct < 0 {
|
||||||
info.UsersFreePct = 0
|
info.UsersFreePct = 0
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
info.UsersUsedPct = 0
|
||||||
|
info.UsersFreePct = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
usageCache.SetDefault(originalsPath, info)
|
usageCache.SetDefault(originalsPath, info)
|
||||||
|
|||||||
@@ -42,6 +42,41 @@ func TestConfig_Usage(t *testing.T) {
|
|||||||
assert.Equal(t, c.Usage().FilesUsed, uint64(0))
|
assert.Equal(t, c.Usage().FilesUsed, uint64(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUsage_Ratios(t *testing.T) {
|
||||||
|
t.Run("FilesUsedRatio", func(t *testing.T) {
|
||||||
|
t.Run("ZeroUsage", func(t *testing.T) {
|
||||||
|
assert.Zero(t, (&Usage{}).FilesUsedRatio())
|
||||||
|
})
|
||||||
|
t.Run("WithTotals", func(t *testing.T) {
|
||||||
|
assert.InEpsilon(t, 0.5, (&Usage{FilesUsed: 50, FilesTotal: 100}).FilesUsedRatio(), 0.001)
|
||||||
|
})
|
||||||
|
t.Run("MissingTotals", func(t *testing.T) {
|
||||||
|
assert.InEpsilon(t, 0.01, (&Usage{FilesUsed: 1, FilesTotal: 0}).FilesUsedRatio(), 0.001)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
t.Run("UsersUsedRatio", func(t *testing.T) {
|
||||||
|
t.Run("NoQuota", func(t *testing.T) {
|
||||||
|
assert.Zero(t, (&Usage{UsersActive: 5, UsersQuota: 0}).UsersUsedRatio())
|
||||||
|
})
|
||||||
|
t.Run("NoActive", func(t *testing.T) {
|
||||||
|
assert.Zero(t, (&Usage{UsersActive: 0, UsersQuota: 10}).UsersUsedRatio())
|
||||||
|
})
|
||||||
|
t.Run("WithQuota", func(t *testing.T) {
|
||||||
|
assert.InEpsilon(t, 0.5, (&Usage{UsersActive: 3, UsersQuota: 6}).UsersUsedRatio(), 0.001)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
t.Run("ActiveCountsNonNegative", func(t *testing.T) {
|
||||||
|
c := TestConfig()
|
||||||
|
FlushUsageCache()
|
||||||
|
c.options.UsersQuota = 0
|
||||||
|
|
||||||
|
usage := c.Usage()
|
||||||
|
|
||||||
|
assert.GreaterOrEqual(t, usage.UsersActive, 0)
|
||||||
|
assert.GreaterOrEqual(t, usage.GuestsActive, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfig_Quota(t *testing.T) {
|
func TestConfig_Quota(t *testing.T) {
|
||||||
c := TestConfig()
|
c := TestConfig()
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func RegisteredUsers() (result entity.Users) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CountUsers returns the number of users based on the specified filter options.
|
// CountUsers returns the number of users based on the specified filter options.
|
||||||
func CountUsers(registered, active bool, roles, excludeRoles []string) (count int) {
|
func CountUsers(registered, active bool, includeRoles, excludeRoles []string) (count int) {
|
||||||
if !Db().HasTable("auth_users") {
|
if !Db().HasTable("auth_users") {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -33,8 +33,8 @@ func CountUsers(registered, active bool, roles, excludeRoles []string) (count in
|
|||||||
stmt = stmt.Where("(can_login > 0 OR webdav > 0) AND user_name <> ''")
|
stmt = stmt.Where("(can_login > 0 OR webdav > 0) AND user_name <> ''")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(roles) > 0 {
|
if len(includeRoles) > 0 {
|
||||||
stmt = stmt.Where("user_role IN (?)", roles)
|
stmt = stmt.Where("user_role IN (?)", includeRoles)
|
||||||
} else if len(excludeRoles) > 0 {
|
} else if len(excludeRoles) > 0 {
|
||||||
stmt = stmt.Where("user_role NOT IN (?)", excludeRoles)
|
stmt = stmt.Where("user_role NOT IN (?)", excludeRoles)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user