mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-11 16:24:11 +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/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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user