verstak-sync-server/internal/server/health.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
}