159 lines
5.8 KiB
Go
159 lines
5.8 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type HealthStatus struct {
|
|
Status string `json:"status"`
|
|
Version string `json:"version"`
|
|
BuildCommit string `json:"build_commit"`
|
|
UptimeSeconds int64 `json:"uptime_seconds"`
|
|
DatabaseReachable bool `json:"database_reachable"`
|
|
BlobStorageWritable bool `json:"blob_storage_writable"`
|
|
SchemaVersion int `json:"schema_version"`
|
|
ServerTime string `json:"server_time"`
|
|
}
|
|
|
|
func (s *Server) healthStatus(ctx context.Context) HealthStatus {
|
|
health := HealthStatus{
|
|
Status: "ok",
|
|
Version: Version,
|
|
BuildCommit: BuildCommit,
|
|
UptimeSeconds: int64(time.Since(s.startedAt).Seconds()),
|
|
ServerTime: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
if s.db == nil || s.db.PingContext(ctx) != nil {
|
|
health.DatabaseReachable = false
|
|
health.Status = "degraded"
|
|
} else {
|
|
health.DatabaseReachable = true
|
|
if err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&health.SchemaVersion); err != nil {
|
|
health.DatabaseReachable = false
|
|
health.Status = "degraded"
|
|
}
|
|
}
|
|
health.BlobStorageWritable = s.blobStorageWritable()
|
|
if !health.BlobStorageWritable {
|
|
health.Status = "degraded"
|
|
}
|
|
return health
|
|
}
|
|
|
|
func (s *Server) blobStorageWritable() bool {
|
|
if s.blobsDir == "" {
|
|
return false
|
|
}
|
|
probe, err := os.CreateTemp(s.blobsDir, ".health-*")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
name := probe.Name()
|
|
if err := probe.Close(); err != nil {
|
|
_ = os.Remove(name)
|
|
return false
|
|
}
|
|
return os.Remove(name) == nil
|
|
}
|
|
|
|
// ServerStats is intentionally independent from the web UI so a future
|
|
// admin panel can expose operational data without coupling to templates.
|
|
type ServerStats struct {
|
|
Users int `json:"users"`
|
|
ActiveUsers int `json:"active_users"`
|
|
BlockedUsers int `json:"blocked_users"`
|
|
UnconfirmedUsers int `json:"unconfirmed_users"`
|
|
ActiveDevices int `json:"active_devices"`
|
|
RevokedDevices int `json:"revoked_devices"`
|
|
Vaults int `json:"vaults"`
|
|
Operations int `json:"operations"`
|
|
Operations24h int `json:"operations_24h"`
|
|
Blobs int `json:"blobs"`
|
|
BlobReferences int `json:"blob_references"`
|
|
OrphanBlobs int `json:"orphan_blobs"`
|
|
TempUploads int `json:"temp_uploads"`
|
|
ExpiredSessions int `json:"expired_sessions"`
|
|
ExpiredTokens int `json:"expired_email_tokens"`
|
|
AuditEvents int `json:"audit_events"`
|
|
DatabaseBytes int64 `json:"database_bytes"`
|
|
BlobBytes int64 `json:"blob_bytes"`
|
|
LastSyncAt string `json:"last_sync_activity"`
|
|
LastCleanupAt string `json:"last_cleanup_at"`
|
|
}
|
|
|
|
func (s *Server) Stats(ctx context.Context) (ServerStats, error) {
|
|
var stats ServerStats
|
|
now := time.Now().UTC()
|
|
queries := []struct {
|
|
query string
|
|
target *int
|
|
}{
|
|
{"SELECT COUNT(*) FROM server_users", &stats.Users},
|
|
{"SELECT COUNT(*) FROM server_users WHERE confirmed=1 AND blocked=0", &stats.ActiveUsers},
|
|
{"SELECT COUNT(*) FROM server_users WHERE blocked=1", &stats.BlockedUsers},
|
|
{"SELECT COUNT(*) FROM server_users WHERE confirmed=0", &stats.UnconfirmedUsers},
|
|
{"SELECT COUNT(*) FROM server_devices WHERE COALESCE(revoked_at, '') = ''", &stats.ActiveDevices},
|
|
{"SELECT COUNT(*) FROM server_devices WHERE COALESCE(revoked_at, '') != ''", &stats.RevokedDevices},
|
|
{"SELECT COUNT(DISTINCT user_id || ':' || vault_id) FROM server_devices WHERE COALESCE(user_id,'') != '' AND COALESCE(vault_id,'') != ''", &stats.Vaults},
|
|
{"SELECT COUNT(*) FROM server_ops", &stats.Operations},
|
|
{"SELECT COUNT(*) FROM server_blobs", &stats.Blobs},
|
|
{"SELECT COUNT(*) FROM server_blob_refs", &stats.BlobReferences},
|
|
{"SELECT COUNT(*) FROM server_blobs b WHERE NOT EXISTS (SELECT 1 FROM server_blob_refs r WHERE r.sha256=b.sha256)", &stats.OrphanBlobs},
|
|
{"SELECT COUNT(*) FROM server_audit_log", &stats.AuditEvents},
|
|
}
|
|
for _, query := range queries {
|
|
if err := s.db.QueryRowContext(ctx, query.query).Scan(query.target); err != nil {
|
|
return ServerStats{}, err
|
|
}
|
|
}
|
|
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM server_ops WHERE created_at >= ?", now.Add(-24*time.Hour).Format(time.RFC3339)).Scan(&stats.Operations24h); err != nil {
|
|
return ServerStats{}, err
|
|
}
|
|
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM server_sessions WHERE expires_at <= ?", now.Format(time.RFC3339)).Scan(&stats.ExpiredSessions); err != nil {
|
|
return ServerStats{}, err
|
|
}
|
|
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM server_email_tokens WHERE expires_at <= ?", now.Format(time.RFC3339)).Scan(&stats.ExpiredTokens); err != nil {
|
|
return ServerStats{}, err
|
|
}
|
|
if err := s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(created_at),'') FROM server_audit_log WHERE event_type='retention_cleanup'").Scan(&stats.LastCleanupAt); err != nil {
|
|
return ServerStats{}, err
|
|
}
|
|
if err := s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(last_seen), '') FROM server_devices").Scan(&stats.LastSyncAt); err != nil {
|
|
return ServerStats{}, err
|
|
}
|
|
if info, err := os.Stat(s.dbPath); err == nil {
|
|
stats.DatabaseBytes = info.Size()
|
|
}
|
|
if err := filepath.WalkDir(s.blobsDir, func(path string, entry fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if entry.Type().IsRegular() {
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stats.BlobBytes += info.Size()
|
|
}
|
|
return nil
|
|
}); err != nil && !os.IsNotExist(err) {
|
|
return ServerStats{}, fmt.Errorf("blob storage stats: %w", err)
|
|
}
|
|
if entries, err := os.ReadDir(s.blobsDir); err == nil {
|
|
for _, entry := range entries {
|
|
if strings.HasPrefix(entry.Name(), ".upload-") {
|
|
stats.TempUploads++
|
|
}
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return ServerStats{}, fmt.Errorf("temporary upload stats: %w", err)
|
|
}
|
|
return stats, nil
|
|
}
|