diff --git a/internal/api/metrics.go b/internal/api/metrics.go index 3c10623c8..25da2404b 100644 --- a/internal/api/metrics.go +++ b/internal/api/metrics.go @@ -14,12 +14,25 @@ import ( "github.com/photoprism/photoprism/internal/auth/acl" "github.com/photoprism/photoprism/internal/config" - "github.com/photoprism/photoprism/internal/entity/query" "github.com/photoprism/photoprism/internal/photoprism/get" reg "github.com/photoprism/photoprism/internal/service/cluster/registry" "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. // // @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.Stream(func(w io.Writer) bool { - reg := prometheus.NewRegistry() - reg.MustRegister(collectors.NewGoCollector()) + registry := prometheus.NewRegistry() + registry.MustRegister(collectors.NewGoCollector()) - factory := promauto.With(reg) + factory := promauto.With(registry) registerCountMetrics(factory, counts) registerBuildInfoMetric(factory, conf.ClientPublic()) @@ -58,7 +71,7 @@ func GetMetrics(router *gin.RouterGroup) { var metrics []*dto.MetricFamily var err error - metrics, err = reg.Gather() + metrics, err = registry.Gather() if err != nil { logErr("metrics", err) @@ -124,8 +137,6 @@ func registerCountMetrics(factory promauto.Factory, counts config.ClientCounts) {"places", counts.Places}, {"labels", counts.Labels}, {"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 { @@ -149,43 +160,58 @@ func registerBuildInfoMetric(factory promauto.Factory, conf *config.ClientConfig } // 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) { filesBytes := factory.NewGaugeVec( prometheus.GaugeOpts{ - Namespace: "photoprism", - Subsystem: "usage", - Name: "files_bytes", - Help: "filesystem usage in bytes for files indexed by this PhotoPrism instance", - }, []string{"state"}, + Namespace: metricsNamespace, + Subsystem: metricsUsageSubsystem, + Name: metricFilesBytes, + Help: metricsFilesBytesHelp, + }, []string{metricsLabelState}, ) - filesBytes.With(prometheus.Labels{"state": "used"}).Set(float64(usage.FilesUsed)) - filesBytes.With(prometheus.Labels{"state": "free"}).Set(float64(usage.FilesFree)) - filesBytes.With(prometheus.Labels{"state": "total"}).Set(float64(usage.FilesTotal)) + filesBytes.With(prometheus.Labels{metricsLabelState: "used"}).Set(float64(usage.FilesUsed)) + filesBytes.With(prometheus.Labels{metricsLabelState: "free"}).Set(float64(usage.FilesFree)) + filesBytes.With(prometheus.Labels{metricsLabelState: "total"}).Set(float64(usage.FilesTotal)) - filesPercent := factory.NewGaugeVec( + filesRatio := factory.NewGaugeVec( prometheus.GaugeOpts{ - Namespace: "photoprism", - Subsystem: "usage", - Name: "files_percent", - Help: "filesystem usage in percent for files indexed by this PhotoPrism instance", - }, []string{"state"}, + Namespace: metricsNamespace, + Subsystem: metricsUsageSubsystem, + Name: metricFilesRatio, + Help: metricsFilesRatioHelp, + }, []string{metricsLabelState}, ) - filesPercent.With(prometheus.Labels{"state": "used"}).Set(float64(usage.FilesUsedPct)) - filesPercent.With(prometheus.Labels{"state": "free"}).Set(float64(usage.FilesFreePct)) + filesUsed := usage.FilesUsedRatio() + 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{ - Namespace: "photoprism", - Subsystem: "usage", - Name: "accounts_percent", - Help: "account quota usage in percent for this PhotoPrism instance", - }, []string{"state"}, + Namespace: metricsNamespace, + Subsystem: metricsUsageSubsystem, + Name: metricAccountsActive, + Help: metricsAccountsHelp, + }, []string{metricsLabelState}, ) - accountsPercent.With(prometheus.Labels{"state": "used"}).Set(float64(usage.UsersUsedPct)) - accountsPercent.With(prometheus.Labels{"state": "free"}).Set(float64(usage.UsersFreePct)) + accountsActive.With(prometheus.Labels{metricsLabelState: "users"}).Set(float64(usage.UsersActive)) + 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. diff --git a/internal/api/metrics_test.go b/internal/api/metrics_test.go index 997bf0dbd..adac242a4 100644 --- a/internal/api/metrics_test.go +++ b/internal/api/metrics_test.go @@ -3,6 +3,7 @@ package api import ( "net/http" "regexp" + "strconv" "testing" "github.com/stretchr/testify/assert" @@ -47,8 +48,6 @@ func TestGetMetrics(t *testing.T) { "people", "labels", "label_max_photos", - "users", - "guests", } 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="free"} `+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_percent{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_percent{state="free"} `+floatPattern), body) + assert.Regexp(t, regexp.MustCompile(`photoprism_usage_files_ratio{state="used"} `+floatPattern), body) + assert.Regexp(t, regexp.MustCompile(`photoprism_usage_files_ratio{state="free"} `+floatPattern), body) + assert.Regexp(t, regexp.MustCompile(`photoprism_usage_accounts_active{state="users"} `+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) { app, router, conf := NewApiTest() diff --git a/internal/config/config_usage.go b/internal/config/config_usage.go index 2b4a04100..37d9fc90e 100644 --- a/internal/config/config_usage.go +++ b/internal/config/config_usage.go @@ -20,14 +20,51 @@ func FlushUsageCache() { } // 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 { + // File storage usage and quota (total). FilesUsed uint64 `json:"filesUsed"` FilesUsedPct int `json:"filesUsedPct"` FilesFree uint64 `json:"filesFree"` FilesFreePct int `json:"filesFreePct"` 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. @@ -67,7 +104,7 @@ func (c *Config) Usage() Usage { } 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 { @@ -80,14 +117,19 @@ func (c *Config) Usage() Usage { info.FilesFreePct = 0 } - if usersTotal := c.UsersQuota(); usersTotal > 0 { - usersUsed := query.CountUsers(true, true, nil, []string{"guest"}) - info.UsersUsedPct = int(math.Floor(float64(usersUsed) / float64(usersTotal) * 100)) + info.UsersActive = query.CountUsers(true, true, nil, []string{"guest"}) + info.GuestsActive = query.CountUsers(true, true, []string{"guest"}, nil) + + if info.UsersQuota = c.UsersQuota(); info.UsersQuota > 0 { + info.UsersUsedPct = int(math.Floor(info.UsersUsedRatio() * 100)) info.UsersFreePct = 100 - info.UsersUsedPct if info.UsersFreePct < 0 { info.UsersFreePct = 0 } + } else { + info.UsersUsedPct = 0 + info.UsersFreePct = 0 } usageCache.SetDefault(originalsPath, info) diff --git a/internal/config/config_usage_test.go b/internal/config/config_usage_test.go index b667c9561..9fb9e1304 100644 --- a/internal/config/config_usage_test.go +++ b/internal/config/config_usage_test.go @@ -42,6 +42,41 @@ func TestConfig_Usage(t *testing.T) { 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) { c := TestConfig() diff --git a/internal/entity/query/users.go b/internal/entity/query/users.go index 77c6edaf6..818394784 100644 --- a/internal/entity/query/users.go +++ b/internal/entity/query/users.go @@ -18,7 +18,7 @@ func RegisteredUsers() (result entity.Users) { } // 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") { 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 <> ''") } - if len(roles) > 0 { - stmt = stmt.Where("user_role IN (?)", roles) + if len(includeRoles) > 0 { + stmt = stmt.Where("user_role IN (?)", includeRoles) } else if len(excludeRoles) > 0 { stmt = stmt.Where("user_role NOT IN (?)", excludeRoles) }