diff --git a/.gitignore b/.gitignore
index 6aff4e36..e078189b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
/node_modules
/.pnp
.pnp.js
+.pnpm-store/
# testing
/coverage
diff --git a/agent/internal/agent/agent.go b/agent/internal/agent/agent.go
index 6a3b4f0b..fa85f72d 100644
--- a/agent/internal/agent/agent.go
+++ b/agent/internal/agent/agent.go
@@ -127,6 +127,9 @@ func NewAgent(
type MetricsSender interface {
SendSystemStats(stats *health.SystemStats, collectedAt time.Time) error
+ SendAgentStats(stats *health.AgentProcessStats, collectedAt time.Time) error
+ SendContainerStats(stats []container.ResourceStats, collectedAt time.Time) error
+ SendPrometheusMetrics(data []byte, extraLabels map[string]string) error
}
func (a *Agent) GetState() AgentState {
diff --git a/agent/internal/agent/drift.go b/agent/internal/agent/drift.go
index 6d083884..18216d87 100644
--- a/agent/internal/agent/drift.go
+++ b/agent/internal/agent/drift.go
@@ -575,13 +575,19 @@ func (a *Agent) updateTraefik() error {
}
needsRestart := false
+ metricsRestart, err := traefik.EnsureMetricsConfig()
+ if err != nil {
+ return fmt.Errorf("failed to ensure Traefik metrics config: %w", err)
+ }
+ needsRestart = metricsRestart
+
if len(tcpPorts) > 0 || len(udpPorts) > 0 {
log.Printf("[reconcile] ensuring L4 entry points: %d TCP, %d UDP", len(tcpPorts), len(udpPorts))
- var err error
- needsRestart, err = traefik.EnsureEntryPoints(tcpPorts, udpPorts)
+ entryPointsRestart, err := traefik.EnsureEntryPoints(tcpPorts, udpPorts)
if err != nil {
return fmt.Errorf("failed to ensure entry points: %w", err)
}
+ needsRestart = needsRestart || entryPointsRestart
}
log.Printf("[reconcile] updating Traefik routes (HTTP: %d, TCP: %d, UDP: %d)", len(expectedHttpRoutes), len(tcpRoutes), len(udpRoutes))
diff --git a/agent/internal/agent/reporting.go b/agent/internal/agent/reporting.go
index d83db657..70f75cc3 100644
--- a/agent/internal/agent/reporting.go
+++ b/agent/internal/agent/reporting.go
@@ -53,6 +53,20 @@ func (a *Agent) BuildStatusReport(includeResources bool) *agenthttp.StatusReport
if err := a.MetricsSender.SendSystemStats(systemStats, collectedAt); err != nil {
log.Printf("[metrics] failed to send system stats: %v", err)
}
+ agentStats, err := health.CollectAgentProcessStats()
+ if err != nil {
+ log.Printf("[metrics] failed to collect agent stats: %v", err)
+ } else if err := a.MetricsSender.SendAgentStats(agentStats, collectedAt); err != nil {
+ log.Printf("[metrics] failed to send agent stats: %v", err)
+ }
+ containerStats, err := container.CollectResourceStats()
+ if err != nil {
+ log.Printf("[metrics] failed to collect container stats: %v", err)
+ return
+ }
+ if err := a.MetricsSender.SendContainerStats(containerStats, collectedAt); err != nil {
+ log.Printf("[metrics] failed to send container stats: %v", err)
+ }
}()
}
report.NetworkHealth = health.CollectNetworkHealth("wg0")
diff --git a/agent/internal/agent/run.go b/agent/internal/agent/run.go
index 220a2982..fbf35ca2 100644
--- a/agent/internal/agent/run.go
+++ b/agent/internal/agent/run.go
@@ -2,13 +2,22 @@ package agent
import (
"context"
+ "fmt"
+ "io"
"log"
+ "net/http"
"time"
"techulus/cloud-agent/internal/container"
"techulus/cloud-agent/internal/serverless"
)
+const (
+ traefikMetricsURL = "http://127.0.0.1:9100/metrics"
+ traefikMetricsInterval = 15 * time.Second
+ traefikMetricsMaxBytes = 8 * 1024 * 1024
+)
+
func (a *Agent) Run(ctx context.Context) {
if a.Config.RegistryURL != "" && a.Config.RegistryUsername != "" && a.Config.RegistryPassword != "" {
if err := container.Login(a.Config.RegistryURL, a.Config.RegistryUsername, a.Config.RegistryPassword, a.Config.RegistryInsecure); err != nil {
@@ -33,6 +42,10 @@ func (a *Agent) Run(ctx context.Context) {
a.TraefikLogCollector.Start()
}
+ if a.IsProxy && a.MetricsSender != nil {
+ go a.TraefikMetricsLoop(ctx)
+ }
+
if a.IsProxy {
gateway := serverless.NewGateway(a)
if err := gateway.Start(ctx); err != nil {
@@ -81,6 +94,58 @@ func (a *Agent) Run(ctx context.Context) {
}
}
+func (a *Agent) TraefikMetricsLoop(ctx context.Context) {
+ ticker := time.NewTicker(traefikMetricsInterval)
+ defer ticker.Stop()
+
+ if err := a.ForwardTraefikMetrics(ctx); err != nil {
+ log.Printf("[traefik-metrics] initial scrape failed: %v", err)
+ }
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ if err := a.ForwardTraefikMetrics(ctx); err != nil {
+ log.Printf("[traefik-metrics] scrape failed: %v", err)
+ }
+ }
+ }
+}
+
+func (a *Agent) ForwardTraefikMetrics(ctx context.Context) error {
+ if a.MetricsSender == nil {
+ return nil
+ }
+
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, traefikMetricsURL, nil)
+ if err != nil {
+ return err
+ }
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ response, err := client.Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode != http.StatusOK {
+ return fmt.Errorf("unexpected status %d", response.StatusCode)
+ }
+
+ body, err := io.ReadAll(io.LimitReader(response.Body, traefikMetricsMaxBytes))
+ if err != nil {
+ return err
+ }
+
+ return a.MetricsSender.SendPrometheusMetrics(body, map[string]string{
+ "job": "traefik",
+ "server_id": a.Config.ServerID,
+ })
+}
+
func (a *Agent) StatusReportLoop(ctx context.Context) {
a.reportStatus("startup")
diff --git a/agent/internal/container/stats.go b/agent/internal/container/stats.go
new file mode 100644
index 00000000..960a2051
--- /dev/null
+++ b/agent/internal/container/stats.go
@@ -0,0 +1,247 @@
+package container
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "math"
+ "os/exec"
+ "strconv"
+ "strings"
+ "unicode"
+)
+
+type ResourceStats struct {
+ ContainerID string
+ ServiceID string
+ DeploymentID string
+ CPUUsagePercent float64
+ MemoryUsagePercent float64
+ MemoryUsedBytes float64
+ NetworkReceiveBytes float64
+ NetworkTransmitBytes float64
+}
+
+func CollectResourceStats() ([]ResourceStats, error) {
+ containers, err := List()
+ if err != nil {
+ return nil, err
+ }
+
+ running := make([]Container, 0, len(containers))
+ args := []string{"stats", "--no-stream", "--format", "json"}
+ for _, c := range containers {
+ if c.State != "running" || c.ServiceID == "" || c.DeploymentID == "" {
+ continue
+ }
+ running = append(running, c)
+ args = append(args, c.ID)
+ }
+ if len(running) == 0 {
+ return nil, nil
+ }
+
+ cmd := exec.Command("podman", args...)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("failed to collect container stats: %s: %w", stderr.String(), err)
+ }
+
+ return parsePodmanStatsOutput(output, running)
+}
+
+func parsePodmanStatsOutput(output []byte, containers []Container) ([]ResourceStats, error) {
+ rows, err := parseStatsRows(output)
+ if err != nil {
+ return nil, err
+ }
+
+ stats := make([]ResourceStats, 0, len(rows))
+ for _, row := range rows {
+ containerID := firstRowString(row, "ID", "Id", "id", "ContainerID", "Container")
+ container := findStatsContainerByID(containerID, containers)
+ if container == nil {
+ name := firstRowString(row, "Name", "Names", "name")
+ container = findStatsContainerByName(name, containers)
+ }
+ if container == nil {
+ continue
+ }
+
+ rx, tx := parseNetIO(firstRowString(row, "NetIO", "NetIOBytes", "net_io"))
+ stats = append(stats, ResourceStats{
+ ContainerID: container.ID,
+ ServiceID: container.ServiceID,
+ DeploymentID: container.DeploymentID,
+ CPUUsagePercent: parsePercent(firstRowString(row, "CPUPerc", "CPU", "cpu_percent")),
+ MemoryUsagePercent: parsePercent(firstRowString(row, "MemPerc", "MEMPerc", "mem_percent")),
+ MemoryUsedBytes: parseMemUsed(firstRowString(row, "MemUsage", "MemUse", "mem_usage")),
+ NetworkReceiveBytes: rx,
+ NetworkTransmitBytes: tx,
+ })
+ }
+
+ return stats, nil
+}
+
+func parseStatsRows(output []byte) ([]map[string]interface{}, error) {
+ trimmed := strings.TrimSpace(string(output))
+ if trimmed == "" {
+ return nil, nil
+ }
+
+ if strings.HasPrefix(trimmed, "[") {
+ var rows []map[string]interface{}
+ if err := json.Unmarshal([]byte(trimmed), &rows); err != nil {
+ return nil, fmt.Errorf("failed to parse podman stats JSON array: %w", err)
+ }
+ return rows, nil
+ }
+
+ var rows []map[string]interface{}
+ for _, line := range strings.Split(trimmed, "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ var row map[string]interface{}
+ if err := json.Unmarshal([]byte(line), &row); err != nil {
+ return nil, fmt.Errorf("failed to parse podman stats JSON row: %w", err)
+ }
+ rows = append(rows, row)
+ }
+ return rows, nil
+}
+
+func findStatsContainerByID(value string, containers []Container) *Container {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return nil
+ }
+
+ for i := range containers {
+ containerID := strings.TrimSpace(containers[i].ID)
+ if value == containerID {
+ return &containers[i]
+ }
+ if containerID != "" && (strings.HasPrefix(containerID, value) || strings.HasPrefix(value, containerID)) {
+ return &containers[i]
+ }
+ }
+ return nil
+}
+
+func findStatsContainerByName(value string, containers []Container) *Container {
+ value = strings.TrimPrefix(strings.TrimSpace(value), "/")
+ if value == "" {
+ return nil
+ }
+
+ for i := range containers {
+ containerName := strings.TrimPrefix(strings.TrimSpace(containers[i].Name), "/")
+ if value == containerName {
+ return &containers[i]
+ }
+ }
+ return nil
+}
+
+func firstRowString(row map[string]interface{}, keys ...string) string {
+ for _, key := range keys {
+ value, ok := row[key]
+ if !ok || value == nil {
+ continue
+ }
+ switch v := value.(type) {
+ case string:
+ return v
+ case []interface{}:
+ if len(v) > 0 {
+ return fmt.Sprint(v[0])
+ }
+ default:
+ return fmt.Sprint(v)
+ }
+ }
+ return ""
+}
+
+func parsePercent(value string) float64 {
+ value = strings.TrimSpace(strings.TrimSuffix(value, "%"))
+ if value == "" || value == "--" {
+ return 0
+ }
+ parsed, err := strconv.ParseFloat(value, 64)
+ if err != nil || !isFinite(parsed) {
+ return 0
+ }
+ return parsed
+}
+
+func parseMemUsed(value string) float64 {
+ parts := strings.Split(value, "/")
+ if len(parts) == 0 {
+ return 0
+ }
+ return parseByteQuantity(parts[0])
+}
+
+func parseNetIO(value string) (float64, float64) {
+ parts := strings.Split(value, "/")
+ if len(parts) != 2 {
+ return 0, 0
+ }
+ return parseByteQuantity(parts[0]), parseByteQuantity(parts[1])
+}
+
+func parseByteQuantity(value string) float64 {
+ value = strings.TrimSpace(value)
+ if value == "" || value == "--" {
+ return 0
+ }
+
+ compact := strings.ReplaceAll(value, " ", "")
+ splitAt := len(compact)
+ for i, r := range compact {
+ if !(unicode.IsDigit(r) || r == '.' || r == '-') {
+ splitAt = i
+ break
+ }
+ }
+
+ numberText := compact[:splitAt]
+ unit := strings.ToLower(compact[splitAt:])
+ parsed, err := strconv.ParseFloat(numberText, 64)
+ if err != nil || !isFinite(parsed) {
+ return 0
+ }
+
+ switch unit {
+ case "", "b":
+ return parsed
+ case "kb", "k", "kib", "ki":
+ return parsed * unitMultiplier(unit, 1)
+ case "mb", "m", "mib", "mi":
+ return parsed * unitMultiplier(unit, 2)
+ case "gb", "g", "gib", "gi":
+ return parsed * unitMultiplier(unit, 3)
+ case "tb", "t", "tib", "ti":
+ return parsed * unitMultiplier(unit, 4)
+ default:
+ return parsed
+ }
+}
+
+func unitMultiplier(unit string, power float64) float64 {
+ base := 1000.0
+ if strings.Contains(unit, "i") {
+ base = 1024.0
+ }
+ return math.Pow(base, power)
+}
+
+func isFinite(value float64) bool {
+ return !math.IsNaN(value) && !math.IsInf(value, 0)
+}
diff --git a/agent/internal/container/stats_test.go b/agent/internal/container/stats_test.go
new file mode 100644
index 00000000..1edfd61b
--- /dev/null
+++ b/agent/internal/container/stats_test.go
@@ -0,0 +1,119 @@
+package container
+
+import "testing"
+
+func TestParsePodmanStatsOutputArray(t *testing.T) {
+ containers := []Container{
+ {
+ ID: "abcdef1234567890",
+ Name: "api",
+ State: "running",
+ ServiceID: "svc_1",
+ DeploymentID: "dep_1",
+ },
+ }
+
+ stats, err := parsePodmanStatsOutput([]byte(`[
+ {
+ "ID": "abcdef123456",
+ "Name": "api",
+ "CPUPerc": "12.34%",
+ "MemUsage": "64MiB / 512MiB",
+ "MemPerc": "12.50%",
+ "NetIO": "1.5MB / 2.5MB"
+ }
+ ]`), containers)
+ if err != nil {
+ t.Fatalf("parse stats: %v", err)
+ }
+ if len(stats) != 1 {
+ t.Fatalf("expected 1 stat, got %d", len(stats))
+ }
+
+ stat := stats[0]
+ if stat.ContainerID != "abcdef1234567890" {
+ t.Fatalf("container id = %q", stat.ContainerID)
+ }
+ if stat.ServiceID != "svc_1" || stat.DeploymentID != "dep_1" {
+ t.Fatalf("unexpected service/deployment labels: %#v", stat)
+ }
+ if stat.CPUUsagePercent != 12.34 {
+ t.Fatalf("cpu = %f", stat.CPUUsagePercent)
+ }
+ if stat.MemoryUsagePercent != 12.5 {
+ t.Fatalf("memory percent = %f", stat.MemoryUsagePercent)
+ }
+ if stat.MemoryUsedBytes != 64*1024*1024 {
+ t.Fatalf("memory bytes = %f", stat.MemoryUsedBytes)
+ }
+ if stat.NetworkReceiveBytes != 1.5*1000*1000 {
+ t.Fatalf("rx bytes = %f", stat.NetworkReceiveBytes)
+ }
+ if stat.NetworkTransmitBytes != 2.5*1000*1000 {
+ t.Fatalf("tx bytes = %f", stat.NetworkTransmitBytes)
+ }
+}
+
+func TestParsePodmanStatsOutputJSONLines(t *testing.T) {
+ containers := []Container{
+ {ID: "1234567890abcdef", Name: "worker", State: "running", ServiceID: "svc_2", DeploymentID: "dep_2"},
+ }
+
+ stats, err := parsePodmanStatsOutput([]byte(`{"ContainerID":"1234567890","CPUPerc":"0%","MemUsage":"128MB / 1GB","MemPerc":"10%","NetIO":"0B / 32kB"}`), containers)
+ if err != nil {
+ t.Fatalf("parse stats: %v", err)
+ }
+ if len(stats) != 1 {
+ t.Fatalf("expected 1 stat, got %d", len(stats))
+ }
+ if stats[0].MemoryUsedBytes != 128*1000*1000 {
+ t.Fatalf("memory bytes = %f", stats[0].MemoryUsedBytes)
+ }
+ if stats[0].NetworkTransmitBytes != 32*1000 {
+ t.Fatalf("tx bytes = %f", stats[0].NetworkTransmitBytes)
+ }
+}
+
+func TestParsePodmanStatsOutputNameFallbackDoesNotUseIDPrefix(t *testing.T) {
+ containers := []Container{
+ {ID: "api1234567890", Name: "backend", State: "running", ServiceID: "wrong", DeploymentID: "wrong_dep"},
+ {ID: "fedcba987654", Name: "api", State: "running", ServiceID: "svc_3", DeploymentID: "dep_3"},
+ }
+
+ stats, err := parsePodmanStatsOutput([]byte(`[
+ {
+ "Name": "api",
+ "CPUPerc": "7%",
+ "MemUsage": "1MiB / 128MiB",
+ "MemPerc": "1%",
+ "NetIO": "0B / 0B"
+ }
+ ]`), containers)
+ if err != nil {
+ t.Fatalf("parse stats: %v", err)
+ }
+ if len(stats) != 1 {
+ t.Fatalf("expected 1 stat, got %d", len(stats))
+ }
+ if stats[0].ServiceID != "svc_3" || stats[0].DeploymentID != "dep_3" {
+ t.Fatalf("unexpected attribution: %#v", stats[0])
+ }
+}
+
+func TestParseByteQuantity(t *testing.T) {
+ tests := map[string]float64{
+ "42B": 42,
+ "1 kB": 1000,
+ "1KiB": 1024,
+ "1.5GB": 1.5 * 1000 * 1000 * 1000,
+ "2 MiB": 2 * 1024 * 1024,
+ "--": 0,
+ "broken": 0,
+ }
+
+ for input, expected := range tests {
+ if actual := parseByteQuantity(input); actual != expected {
+ t.Fatalf("%q = %f, want %f", input, actual, expected)
+ }
+ }
+}
diff --git a/agent/internal/health/health.go b/agent/internal/health/health.go
index 3d140cdc..f23ce9d7 100644
--- a/agent/internal/health/health.go
+++ b/agent/internal/health/health.go
@@ -1,14 +1,19 @@
package health
import (
+ "math"
+ "os"
"os/exec"
+ "runtime"
"strconv"
"strings"
+ "sync"
"time"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/mem"
+ "github.com/shirou/gopsutil/v3/process"
)
type SystemStats struct {
@@ -19,6 +24,12 @@ type SystemStats struct {
DiskUsedGb int `json:"diskUsedGb"`
}
+type AgentProcessStats struct {
+ CPUUsagePercent float64
+ MemoryUsagePercent float64
+ MemoryUsedBytes uint64
+}
+
type NetworkPeerHealth struct {
ID string `json:"id"`
LastSeenSecs int `json:"lastSeenSecs"`
@@ -45,6 +56,12 @@ type AgentHealthInfo struct {
LastSyncAt string `json:"lastSyncAt"`
}
+var (
+ agentProcessCPUMu sync.Mutex
+ agentProcessLastCPUTimes *cpu.TimesStat
+ agentProcessLastCPUTime time.Time
+)
+
func CollectSystemStats() *SystemStats {
stats := &SystemStats{}
@@ -68,6 +85,89 @@ func CollectSystemStats() *SystemStats {
return stats
}
+func CollectAgentProcessStats() (*AgentProcessStats, error) {
+ proc, err := process.NewProcess(int32(os.Getpid()))
+ if err != nil {
+ return nil, err
+ }
+
+ stats := &AgentProcessStats{}
+
+ cpuPercent, err := collectAgentCPUPercent(proc)
+ if err != nil {
+ return nil, err
+ }
+ stats.CPUUsagePercent = cpuPercent
+
+ memInfo, err := proc.MemoryInfo()
+ if err != nil {
+ return nil, err
+ }
+ stats.MemoryUsedBytes = memInfo.RSS
+
+ memPercent, err := proc.MemoryPercent()
+ if err != nil {
+ return nil, err
+ }
+ stats.MemoryUsagePercent = float64(memPercent)
+
+ return stats, nil
+}
+
+func collectAgentCPUPercent(proc *process.Process) (float64, error) {
+ cpuTimes, err := proc.Times()
+ if err != nil {
+ return 0, err
+ }
+ now := time.Now()
+
+ agentProcessCPUMu.Lock()
+ defer agentProcessCPUMu.Unlock()
+
+ if agentProcessLastCPUTimes == nil || agentProcessLastCPUTime.IsZero() {
+ // Delta-based CPU metrics need one sample to establish the baseline.
+ agentProcessLastCPUTimes = cpuTimes
+ agentProcessLastCPUTime = now
+ return 0, nil
+ }
+
+ elapsedSeconds := now.Sub(agentProcessLastCPUTime).Seconds()
+ percent := calculateAgentCPUUsagePercent(
+ agentProcessLastCPUTimes,
+ cpuTimes,
+ elapsedSeconds,
+ runtime.NumCPU(),
+ )
+ agentProcessLastCPUTimes = cpuTimes
+ agentProcessLastCPUTime = now
+
+ return percent, nil
+}
+
+func calculateAgentCPUUsagePercent(previous, current *cpu.TimesStat, elapsedSeconds float64, cpuCount int) float64 {
+ if previous == nil || current == nil {
+ return 0
+ }
+
+ cpuDeltaSeconds := processCPUTotal(current) - processCPUTotal(previous)
+ if elapsedSeconds <= 0 || cpuDeltaSeconds < 0 {
+ return 0
+ }
+ if cpuCount <= 0 {
+ cpuCount = 1
+ }
+
+ percent := (cpuDeltaSeconds / elapsedSeconds) * 100 / float64(cpuCount)
+ if math.IsNaN(percent) || math.IsInf(percent, 0) {
+ return 0
+ }
+ return percent
+}
+
+func processCPUTotal(times *cpu.TimesStat) float64 {
+ return times.User + times.System
+}
+
func CollectNetworkHealth(interfaceName string) *NetworkHealth {
health := &NetworkHealth{
TunnelUp: false,
diff --git a/agent/internal/health/health_test.go b/agent/internal/health/health_test.go
new file mode 100644
index 00000000..1e169e9a
--- /dev/null
+++ b/agent/internal/health/health_test.go
@@ -0,0 +1,67 @@
+package health
+
+import (
+ "testing"
+
+ "github.com/shirou/gopsutil/v3/cpu"
+)
+
+func TestCalculateAgentCPUUsagePercentNormalizesByCPUCount(t *testing.T) {
+ previous := &cpu.TimesStat{User: 10, System: 5}
+ current := &cpu.TimesStat{User: 12, System: 7}
+
+ got := calculateAgentCPUUsagePercent(previous, current, 1, 4)
+ if got != 100 {
+ t.Fatalf("cpu percent = %f, want 100", got)
+ }
+}
+
+func TestCalculateAgentCPUUsagePercentWarmupOrInvalidInputs(t *testing.T) {
+ current := &cpu.TimesStat{User: 12, System: 7}
+
+ tests := map[string]struct {
+ previous *cpu.TimesStat
+ current *cpu.TimesStat
+ elapsedSeconds float64
+ cpuCount int
+ }{
+ "missing previous sample": {
+ previous: nil,
+ current: current,
+ elapsedSeconds: 1,
+ cpuCount: 4,
+ },
+ "missing current sample": {
+ previous: &cpu.TimesStat{User: 10, System: 5},
+ current: nil,
+ elapsedSeconds: 1,
+ cpuCount: 4,
+ },
+ "zero elapsed": {
+ previous: &cpu.TimesStat{User: 10, System: 5},
+ current: current,
+ elapsedSeconds: 0,
+ cpuCount: 4,
+ },
+ "negative counter delta": {
+ previous: current,
+ current: &cpu.TimesStat{User: 10, System: 5},
+ elapsedSeconds: 1,
+ cpuCount: 4,
+ },
+ }
+
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+ got := calculateAgentCPUUsagePercent(
+ test.previous,
+ test.current,
+ test.elapsedSeconds,
+ test.cpuCount,
+ )
+ if got != 0 {
+ t.Fatalf("cpu percent = %f, want 0", got)
+ }
+ })
+ }
+}
diff --git a/agent/internal/metrics/victoria.go b/agent/internal/metrics/victoria.go
index 8d90e677..1bedd144 100644
--- a/agent/internal/metrics/victoria.go
+++ b/agent/internal/metrics/victoria.go
@@ -5,9 +5,11 @@ import (
"fmt"
"net/http"
"net/url"
+ "sort"
"strings"
"time"
+ "techulus/cloud-agent/internal/container"
"techulus/cloud-agent/internal/health"
)
@@ -19,6 +21,15 @@ type VictoriaMetricsSender struct {
client *http.Client
}
+type serviceResourceStats struct {
+ ServiceID string
+ CPUUsagePercent float64
+ MemoryUsagePercent float64
+ MemoryUsedBytes float64
+ NetworkReceiveBytes float64
+ NetworkTransmitBytes float64
+}
+
func NewVictoriaMetricsSender(endpoint, serverID string) *VictoriaMetricsSender {
var username, password string
cleanEndpoint := strings.TrimRight(endpoint, "/")
@@ -78,8 +89,147 @@ func (v *VictoriaMetricsSender) SendSystemStats(stats *health.SystemStats, colle
return nil
}
+func (v *VictoriaMetricsSender) SendAgentStats(stats *health.AgentProcessStats, collectedAt time.Time) error {
+ if stats == nil {
+ return nil
+ }
+
+ timestampMs := collectedAt.UnixMilli()
+ serverID := escapeLabelValue(v.serverID)
+
+ var buf bytes.Buffer
+ writeGauge(&buf, "techulus_agent_cpu_usage_percent", serverID, stats.CPUUsagePercent, timestampMs)
+ writeGauge(&buf, "techulus_agent_memory_usage_percent", serverID, stats.MemoryUsagePercent, timestampMs)
+ writeGauge(&buf, "techulus_agent_memory_used_bytes", serverID, float64(stats.MemoryUsedBytes), timestampMs)
+
+ return v.postPrometheusImport(buf.Bytes(), nil)
+}
+
+func (v *VictoriaMetricsSender) SendContainerStats(stats []container.ResourceStats, collectedAt time.Time) error {
+ if len(stats) == 0 {
+ return nil
+ }
+
+ aggregates := aggregateContainerStats(stats)
+ if len(aggregates) == 0 {
+ return nil
+ }
+
+ timestampMs := collectedAt.UnixMilli()
+ serverID := escapeLabelValue(v.serverID)
+
+ var buf bytes.Buffer
+ for _, stat := range aggregates {
+ labels := map[string]string{
+ "server_id": serverID,
+ "service_id": escapeLabelValue(stat.ServiceID),
+ }
+ writeGaugeWithLabels(&buf, "techulus_service_cpu_usage_percent", labels, stat.CPUUsagePercent, timestampMs)
+ writeGaugeWithLabels(&buf, "techulus_service_memory_usage_percent", labels, stat.MemoryUsagePercent, timestampMs)
+ writeGaugeWithLabels(&buf, "techulus_service_memory_used_bytes", labels, stat.MemoryUsedBytes, timestampMs)
+ writeGaugeWithLabels(&buf, "techulus_service_network_receive_bytes_total", labels, stat.NetworkReceiveBytes, timestampMs)
+ writeGaugeWithLabels(&buf, "techulus_service_network_transmit_bytes_total", labels, stat.NetworkTransmitBytes, timestampMs)
+ }
+
+ return v.postPrometheusImport(buf.Bytes(), nil)
+}
+
+func aggregateContainerStats(stats []container.ResourceStats) []serviceResourceStats {
+ byService := make(map[string]*serviceResourceStats)
+ for _, stat := range stats {
+ if stat.ServiceID == "" {
+ continue
+ }
+ aggregate := byService[stat.ServiceID]
+ if aggregate == nil {
+ aggregate = &serviceResourceStats{ServiceID: stat.ServiceID}
+ byService[stat.ServiceID] = aggregate
+ }
+ aggregate.CPUUsagePercent += stat.CPUUsagePercent
+ aggregate.MemoryUsagePercent += stat.MemoryUsagePercent
+ aggregate.MemoryUsedBytes += stat.MemoryUsedBytes
+ aggregate.NetworkReceiveBytes += stat.NetworkReceiveBytes
+ aggregate.NetworkTransmitBytes += stat.NetworkTransmitBytes
+ }
+
+ serviceIDs := make([]string, 0, len(byService))
+ for serviceID := range byService {
+ serviceIDs = append(serviceIDs, serviceID)
+ }
+ sort.Strings(serviceIDs)
+
+ aggregates := make([]serviceResourceStats, 0, len(serviceIDs))
+ for _, serviceID := range serviceIDs {
+ aggregates = append(aggregates, *byService[serviceID])
+ }
+ return aggregates
+}
+
+func (v *VictoriaMetricsSender) SendPrometheusMetrics(data []byte, extraLabels map[string]string) error {
+ if len(bytes.TrimSpace(data)) == 0 {
+ return nil
+ }
+ return v.postPrometheusImport(data, extraLabels)
+}
+
+func (v *VictoriaMetricsSender) postPrometheusImport(data []byte, extraLabels map[string]string) error {
+ requestURL, err := url.Parse(v.endpoint + "/api/v1/import/prometheus")
+ if err != nil {
+ return fmt.Errorf("failed to parse metrics endpoint: %w", err)
+ }
+ query := requestURL.Query()
+ for _, key := range sortedKeys(extraLabels) {
+ if key == "" || extraLabels[key] == "" {
+ continue
+ }
+ query.Add("extra_label", fmt.Sprintf("%s=%s", key, extraLabels[key]))
+ }
+ requestURL.RawQuery = query.Encode()
+
+ req, err := http.NewRequest("POST", requestURL.String(), bytes.NewReader(data))
+ if err != nil {
+ return fmt.Errorf("failed to create metrics request: %w", err)
+ }
+ req.Header.Set("Content-Type", "text/plain; version=0.0.4")
+ if v.username != "" {
+ req.SetBasicAuth(v.username, v.password)
+ }
+
+ resp, err := v.client.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to send metrics: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
+ return fmt.Errorf("unexpected metrics status code: %d", resp.StatusCode)
+ }
+
+ return nil
+}
+
func writeGauge(buf *bytes.Buffer, name, serverID string, value float64, timestampMs int64) {
- fmt.Fprintf(buf, "%s{server_id=\"%s\"} %f %d\n", name, serverID, value, timestampMs)
+ writeGaugeWithLabels(buf, name, map[string]string{"server_id": serverID}, value, timestampMs)
+}
+
+func writeGaugeWithLabels(buf *bytes.Buffer, name string, labels map[string]string, value float64, timestampMs int64) {
+ fmt.Fprintf(buf, "%s{", name)
+ for i, key := range sortedKeys(labels) {
+ if i > 0 {
+ buf.WriteByte(',')
+ }
+ fmt.Fprintf(buf, "%s=\"%s\"", key, labels[key])
+ }
+ fmt.Fprintf(buf, "} %f %d\n", value, timestampMs)
+}
+
+func sortedKeys(values map[string]string) []string {
+ keys := make([]string, 0, len(values))
+ for key := range values {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ return keys
}
func escapeLabelValue(value string) string {
diff --git a/agent/internal/metrics/victoria_test.go b/agent/internal/metrics/victoria_test.go
new file mode 100644
index 00000000..ff691eb2
--- /dev/null
+++ b/agent/internal/metrics/victoria_test.go
@@ -0,0 +1,126 @@
+package metrics
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "techulus/cloud-agent/internal/container"
+ "techulus/cloud-agent/internal/health"
+)
+
+func TestSendPrometheusMetricsAddsExtraLabels(t *testing.T) {
+ var gotPath string
+ var gotQuery string
+ var gotBody string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ gotQuery = r.URL.RawQuery
+ body, _ := io.ReadAll(r.Body)
+ gotBody = string(body)
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+
+ sender := NewVictoriaMetricsSender(server.URL, "server-1")
+ err := sender.SendPrometheusMetrics([]byte("sample_metric 1\n"), map[string]string{
+ "server_id": "server-1",
+ "job": "traefik",
+ })
+ if err != nil {
+ t.Fatalf("send prometheus metrics: %v", err)
+ }
+
+ if gotPath != "/api/v1/import/prometheus" {
+ t.Fatalf("path = %q", gotPath)
+ }
+ if gotQuery != "extra_label=job%3Dtraefik&extra_label=server_id%3Dserver-1" {
+ t.Fatalf("query = %q", gotQuery)
+ }
+ if gotBody != "sample_metric 1\n" {
+ t.Fatalf("body = %q", gotBody)
+ }
+}
+
+func TestSendAgentStatsWritesStableServerLabels(t *testing.T) {
+ var gotBody string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ gotBody = string(body)
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+
+ sender := NewVictoriaMetricsSender(server.URL, "server-1")
+ err := sender.SendAgentStats(&health.AgentProcessStats{
+ CPUUsagePercent: 1.25,
+ MemoryUsagePercent: 0.5,
+ MemoryUsedBytes: 64 * 1024 * 1024,
+ }, time.UnixMilli(1_700_000_000_000))
+ if err != nil {
+ t.Fatalf("send agent stats: %v", err)
+ }
+
+ if !strings.Contains(gotBody, `techulus_agent_cpu_usage_percent{server_id="server-1"} 1.250000 1700000000000`) {
+ t.Fatalf("missing agent CPU metric:\n%s", gotBody)
+ }
+ if !strings.Contains(gotBody, `techulus_agent_memory_usage_percent{server_id="server-1"} 0.500000 1700000000000`) {
+ t.Fatalf("missing agent memory percent metric:\n%s", gotBody)
+ }
+ if !strings.Contains(gotBody, `techulus_agent_memory_used_bytes{server_id="server-1"} 67108864.000000 1700000000000`) {
+ t.Fatalf("missing agent memory bytes metric:\n%s", gotBody)
+ }
+}
+
+func TestSendContainerStatsAggregatesStableServiceLabels(t *testing.T) {
+ var gotBody string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ gotBody = string(body)
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+
+ sender := NewVictoriaMetricsSender(server.URL, "server-1")
+ err := sender.SendContainerStats([]container.ResourceStats{
+ {
+ ContainerID: "container-a",
+ ServiceID: "svc-a",
+ DeploymentID: "dep-a",
+ CPUUsagePercent: 10,
+ MemoryUsagePercent: 1.5,
+ MemoryUsedBytes: 1024,
+ NetworkReceiveBytes: 100,
+ NetworkTransmitBytes: 200,
+ },
+ {
+ ContainerID: "container-b",
+ ServiceID: "svc-a",
+ DeploymentID: "dep-b",
+ CPUUsagePercent: 20,
+ MemoryUsagePercent: 2.5,
+ MemoryUsedBytes: 2048,
+ NetworkReceiveBytes: 300,
+ NetworkTransmitBytes: 400,
+ },
+ }, time.UnixMilli(1_700_000_000_000))
+ if err != nil {
+ t.Fatalf("send container stats: %v", err)
+ }
+
+ if strings.Contains(gotBody, "container_id") || strings.Contains(gotBody, "deployment_id") {
+ t.Fatalf("unexpected churn labels in body:\n%s", gotBody)
+ }
+ if !strings.Contains(gotBody, `techulus_service_cpu_usage_percent{server_id="server-1",service_id="svc-a"} 30.000000 1700000000000`) {
+ t.Fatalf("missing aggregated CPU metric:\n%s", gotBody)
+ }
+ if !strings.Contains(gotBody, `techulus_service_memory_usage_percent{server_id="server-1",service_id="svc-a"} 4.000000 1700000000000`) {
+ t.Fatalf("missing aggregated memory percent metric:\n%s", gotBody)
+ }
+ if !strings.Contains(gotBody, `techulus_service_memory_used_bytes{server_id="server-1",service_id="svc-a"} 3072.000000 1700000000000`) {
+ t.Fatalf("missing aggregated memory bytes metric:\n%s", gotBody)
+ }
+}
diff --git a/agent/internal/traefik/static.go b/agent/internal/traefik/static.go
index 3c7702ff..9fa007dd 100644
--- a/agent/internal/traefik/static.go
+++ b/agent/internal/traefik/static.go
@@ -5,11 +5,35 @@ import (
"log"
"os"
"os/exec"
+ "reflect"
"time"
"gopkg.in/yaml.v3"
)
+const (
+ metricsEntryPointName = "metrics"
+ metricsEntryPointAddr = "127.0.0.1:9100"
+)
+
+var prometheusLatencyBuckets = []interface{}{
+ 0.005,
+ 0.01,
+ 0.025,
+ 0.05,
+ 0.075,
+ 0.1,
+ 0.25,
+ 0.5,
+ 0.75,
+ 1.0,
+ 2.5,
+ 5.0,
+ 10.0,
+ 30.0,
+ 60.0,
+}
+
func validateStaticConfig(data []byte) error {
var config map[string]interface{}
if err := yaml.Unmarshal(data, &config); err != nil {
@@ -112,6 +136,95 @@ func EnsureEntryPoints(tcpPorts []int, udpPorts []int) (needsRestart bool, err e
return true, nil
}
+func EnsureMetricsConfig() (needsRestart bool, err error) {
+ originalData, err := os.ReadFile(traefikStaticConfigPath)
+ if err != nil {
+ return false, fmt.Errorf("failed to read static config: %w", err)
+ }
+
+ var config map[string]interface{}
+ if err := yaml.Unmarshal(originalData, &config); err != nil {
+ return false, fmt.Errorf("failed to parse static config: %w", err)
+ }
+
+ if !ensurePrometheusMetricsConfig(config) {
+ return false, nil
+ }
+
+ newData, err := yaml.Marshal(config)
+ if err != nil {
+ return false, fmt.Errorf("failed to marshal static config: %w", err)
+ }
+
+ if err := validateStaticConfig(newData); err != nil {
+ return false, fmt.Errorf("config validation failed: %w", err)
+ }
+
+ if err := atomicWrite(traefikStaticConfigPath, newData, 0644); err != nil {
+ return false, fmt.Errorf("failed to write static config: %w", err)
+ }
+
+ log.Printf("[traefik] metrics config updated, restart required")
+ return true, nil
+}
+
+func ensurePrometheusMetricsConfig(config map[string]interface{}) bool {
+ modified := false
+
+ entryPoints, ok := config["entryPoints"].(map[string]interface{})
+ if !ok {
+ entryPoints = make(map[string]interface{})
+ config["entryPoints"] = entryPoints
+ modified = true
+ }
+
+ metricsEntryPoint, ok := entryPoints[metricsEntryPointName].(map[string]interface{})
+ if !ok {
+ metricsEntryPoint = make(map[string]interface{})
+ entryPoints[metricsEntryPointName] = metricsEntryPoint
+ modified = true
+ }
+ if setMapValue(metricsEntryPoint, "address", metricsEntryPointAddr) {
+ modified = true
+ }
+
+ metricsConfig, ok := config["metrics"].(map[string]interface{})
+ if !ok {
+ metricsConfig = make(map[string]interface{})
+ config["metrics"] = metricsConfig
+ modified = true
+ }
+
+ prometheusConfig, ok := metricsConfig["prometheus"].(map[string]interface{})
+ if !ok {
+ prometheusConfig = make(map[string]interface{})
+ metricsConfig["prometheus"] = prometheusConfig
+ modified = true
+ }
+
+ for key, value := range map[string]interface{}{
+ "entryPoint": metricsEntryPointName,
+ "addEntryPointsLabels": false,
+ "addRoutersLabels": false,
+ "addServicesLabels": true,
+ "buckets": prometheusLatencyBuckets,
+ } {
+ if setMapValue(prometheusConfig, key, value) {
+ modified = true
+ }
+ }
+
+ return modified
+}
+
+func setMapValue(values map[string]interface{}, key string, value interface{}) bool {
+ if reflect.DeepEqual(values[key], value) {
+ return false
+ }
+ values[key] = value
+ return true
+}
+
func ReloadTraefik() error {
cmd := exec.Command("systemctl", "restart", "traefik")
if err := cmd.Run(); err != nil {
diff --git a/agent/internal/traefik/static_test.go b/agent/internal/traefik/static_test.go
new file mode 100644
index 00000000..7f2b47af
--- /dev/null
+++ b/agent/internal/traefik/static_test.go
@@ -0,0 +1,50 @@
+package traefik
+
+import "testing"
+
+func TestEnsurePrometheusMetricsConfigAddsPrivateMetricsEndpoint(t *testing.T) {
+ config := map[string]interface{}{
+ "entryPoints": map[string]interface{}{
+ "web": map[string]interface{}{"address": ":80"},
+ },
+ }
+
+ if !ensurePrometheusMetricsConfig(config) {
+ t.Fatal("expected config to be modified")
+ }
+
+ entryPoints := config["entryPoints"].(map[string]interface{})
+ metricsEntryPoint := entryPoints["metrics"].(map[string]interface{})
+ if metricsEntryPoint["address"] != "127.0.0.1:9100" {
+ t.Fatalf("metrics address = %#v", metricsEntryPoint["address"])
+ }
+
+ metricsConfig := config["metrics"].(map[string]interface{})
+ prometheusConfig := metricsConfig["prometheus"].(map[string]interface{})
+ if prometheusConfig["entryPoint"] != "metrics" {
+ t.Fatalf("entryPoint = %#v", prometheusConfig["entryPoint"])
+ }
+ if prometheusConfig["addServicesLabels"] != true {
+ t.Fatalf("addServicesLabels = %#v", prometheusConfig["addServicesLabels"])
+ }
+ if prometheusConfig["addRoutersLabels"] != false {
+ t.Fatalf("addRoutersLabels = %#v", prometheusConfig["addRoutersLabels"])
+ }
+ if prometheusConfig["addEntryPointsLabels"] != false {
+ t.Fatalf("addEntryPointsLabels = %#v", prometheusConfig["addEntryPointsLabels"])
+ }
+ if len(prometheusConfig["buckets"].([]interface{})) == 0 {
+ t.Fatal("expected latency buckets")
+ }
+}
+
+func TestEnsurePrometheusMetricsConfigIsStable(t *testing.T) {
+ config := map[string]interface{}{}
+
+ if !ensurePrometheusMetricsConfig(config) {
+ t.Fatal("expected first call to modify config")
+ }
+ if ensurePrometheusMetricsConfig(config) {
+ t.Fatal("expected second call to be stable")
+ }
+}
diff --git a/web/actions/projects.ts b/web/actions/projects.ts
index 828a2575..a384a25f 100644
--- a/web/actions/projects.ts
+++ b/web/actions/projects.ts
@@ -1,9 +1,11 @@
"use server";
import { randomUUID } from "node:crypto";
+import { isAPIError } from "better-auth/api";
import cronstrue from "cronstrue";
import { and, desc, eq, inArray, isNotNull, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
+import { headers } from "next/headers";
import { ZodError, z } from "zod";
import { db } from "@/db";
import {
@@ -28,7 +30,7 @@ import {
volumeBackups,
workQueue,
} from "@/db/schema";
-import { requireDeveloperRole } from "@/lib/auth";
+import { auth, requireDeveloperRole } from "@/lib/auth";
import { DEFAULT_RESOURCE_LIMITS } from "@/lib/constants";
import { deployServiceInternal } from "@/lib/deploy-service";
import {
@@ -47,15 +49,19 @@ import {
nameSchema,
volumeNameSchema,
} from "@/lib/schemas";
-import { MIN_SERVERLESS_SLEEP_AFTER_SECONDS } from "@/lib/service-config";
import type {
PortConfig,
HealthCheckConfig as ServiceHealthCheckConfig,
} from "@/lib/service-config";
+import { MIN_SERVERLESS_SLEEP_AFTER_SECONDS } from "@/lib/service-config";
import { getZodErrorMessage, slugify } from "@/lib/utils";
import { enqueueWork } from "@/lib/work-queue";
import { deleteBackup } from "./backups";
+type ServiceDeleteConfirmation = {
+ totpCode?: string;
+};
+
function isValidImageReferencePart(reference: string): boolean {
const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
const digestPattern = /^[A-Za-z0-9_+.-]+:[0-9a-fA-F]{32,256}$/;
@@ -574,8 +580,39 @@ async function hardDeleteService(serviceId: string) {
return { success: true };
}
-export async function deleteService(serviceId: string) {
- await requireDeveloperRole();
+export async function deleteService(
+ serviceId: string,
+ confirmation?: ServiceDeleteConfirmation,
+) {
+ const session = await requireDeveloperRole();
+ if (!session) {
+ throw new Error("Unauthorized");
+ }
+
+ const twoFactorEnabled = Boolean(
+ (session.user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled,
+ );
+
+ if (twoFactorEnabled) {
+ const totpCode = confirmation?.totpCode ?? "";
+
+ if (!/^\d{6}$/.test(totpCode)) {
+ throw new Error("Authenticator code is required to delete this service");
+ }
+
+ try {
+ await auth.api.verifyTOTP({
+ body: { code: totpCode },
+ headers: await headers(),
+ });
+ } catch (error) {
+ if (isAPIError(error)) {
+ throw new Error("Invalid authenticator code");
+ }
+ throw error;
+ }
+ }
+
const service = await getService(serviceId);
if (!service) {
throw new Error("Service not found");
diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx
index 77700a12..138c0c28 100644
--- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx
+++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx
@@ -1,5 +1,6 @@
"use client";
+import { REGEXP_ONLY_DIGITS } from "input-otp";
import { Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
@@ -30,10 +31,22 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
+import {
+ InputOTP,
+ InputOTPGroup,
+ InputOTPSlot,
+} from "@/components/ui/input-otp";
import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item";
+import { Label } from "@/components/ui/label";
+import { Spinner } from "@/components/ui/spinner";
+import { useSession } from "@/lib/auth-client";
const ACTIVE_DELETE_BACKUP_STATUSES = ["running", "healthy"] as const;
+type TwoFactorSessionUser = {
+ twoFactorEnabled?: boolean | null;
+};
+
function formatBackupDate(value: Date | string | null | undefined) {
if (!value) return "an unknown time";
return new Date(value).toLocaleString();
@@ -42,8 +55,15 @@ function formatBackupDate(value: Date | string | null | undefined) {
export default function ConfigurationPage() {
const router = useRouter();
const { mutate: globalMutate } = useSWRConfig();
+ const { data: session, isPending: isSessionLoading } = useSession();
+ const sessionUser = session?.user as TwoFactorSessionUser | undefined;
const { service, projectSlug, envName, proxyDomain, onUpdate } = useService();
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [deleteTotpCode, setDeleteTotpCode] = useState("");
const [isDeleting, setIsDeleting] = useState(false);
+ const requiresDeleteConfirmation = Boolean(sessionUser?.twoFactorEnabled);
+ const isDeleteConfirmationIncomplete =
+ requiresDeleteConfirmation && deleteTotpCode.length !== 6;
const hasActiveDeploymentForBackup = service.deployments.some(
(deployment) =>
ACTIVE_DELETE_BACKUP_STATUSES.includes(
@@ -63,15 +83,38 @@ export default function ConfigurationPage() {
toast.info("Changes saved. Deploy to apply them.");
}, [onUpdate]);
+ const resetDeleteConfirmation = useCallback(() => {
+ setDeleteTotpCode("");
+ }, []);
+
const handleDelete = async () => {
+ if (isDeleteConfirmationIncomplete) {
+ toast.error("Enter your 6-digit authenticator code");
+ return;
+ }
+
setIsDeleting(true);
try {
- await deleteService(service.id);
+ await deleteService(
+ service.id,
+ requiresDeleteConfirmation
+ ? {
+ totpCode: deleteTotpCode,
+ }
+ : undefined,
+ );
await globalMutate(`/api/projects/${service.projectId}/services`);
toast.success(
service.stateful ? "Delete workflow started" : "Service deleted",
);
+ setDeleteDialogOpen(false);
+ resetDeleteConfirmation();
router.push(`/dashboard/projects/${projectSlug}/${envName}`);
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to delete service",
+ );
+ resetDeleteConfirmation();
} finally {
setIsDeleting(false);
}
@@ -120,11 +163,18 @@ export default function ConfigurationPage() {
: "Once deleted, this service and all its deployments will be permanently removed."}
-
+ {
+ if (isDeleting) return;
+ setDeleteDialogOpen(open);
+ if (!open) resetDeleteConfirmation();
+ }}
+ >
}>
Delete Service
-
+
Delete {service.name}?
@@ -167,15 +217,59 @@ export default function ConfigurationPage() {
) : (
"This action cannot be undone. This will permanently delete the service and all its deployments."
)}
+ {requiresDeleteConfirmation && (
+ <>
+
+
+ Enter your authenticator code to confirm this deletion.
+ >
+ )}
+ {requiresDeleteConfirmation && (
+
+
+
+ Authenticator code
+
+
+ setDeleteTotpCode(value.replace(/\D/g, ""))
+ }
+ disabled={isDeleting}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ )}
- Cancel
+
+ Cancel
+
+ {isDeleting ? : null}
{isDeleting ? "Deleting..." : "Delete"}
diff --git a/web/app/api/services/[id]/request-stats/route.ts b/web/app/api/services/[id]/metrics/route.ts
similarity index 61%
rename from web/app/api/services/[id]/request-stats/route.ts
rename to web/app/api/services/[id]/metrics/route.ts
index 3b25c5be..1d8cc791 100644
--- a/web/app/api/services/[id]/request-stats/route.ts
+++ b/web/app/api/services/[id]/metrics/route.ts
@@ -2,11 +2,12 @@ import { headers } from "next/headers";
import { getService } from "@/db/queries";
import { auth } from "@/lib/auth";
import {
- createEmptyHttpRequestStats,
- isLoggingEnabled,
- parseRequestStatsRange,
- queryHttpRequestStats,
-} from "@/lib/victoria-logs";
+ createEmptyServiceMetrics,
+ isMetricsEnabled,
+ parseMetricRange,
+ queryServiceMetrics,
+ warnMissingMetricsConfig,
+} from "@/lib/victoria-metrics";
const SERVICE_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -25,7 +26,7 @@ export async function GET(
const { id: serviceId } = await params;
const url = new URL(request.url);
- const range = parseRequestStatsRange(url.searchParams.get("range"));
+ const range = parseMetricRange(url.searchParams.get("range"));
if (!SERVICE_ID_PATTERN.test(serviceId)) {
return Response.json({ message: "Invalid service id" }, { status: 400 });
@@ -36,20 +37,17 @@ export async function GET(
return Response.json({ message: "Service not found" }, { status: 404 });
}
- if (!isLoggingEnabled()) {
- return Response.json({
- loggingEnabled: false,
- ...createEmptyHttpRequestStats(range),
- });
+ if (!isMetricsEnabled()) {
+ warnMissingMetricsConfig("service");
+ return Response.json(createEmptyServiceMetrics(range));
}
try {
- const stats = await queryHttpRequestStats({ serviceId, range });
- return Response.json({ loggingEnabled: true, ...stats });
+ return Response.json(await queryServiceMetrics({ serviceId, range }));
} catch (error) {
- console.error("[logs:request-stats] failed to query HTTP stats:", error);
+ console.error("[metrics:service] failed to query service metrics:", error);
return Response.json(
- { message: "Request stats unavailable" },
+ { message: "Service metrics unavailable" },
{ status: 502 },
);
}
diff --git a/web/app/globals.css b/web/app/globals.css
index 24cec57e..33dea139 100644
--- a/web/app/globals.css
+++ b/web/app/globals.css
@@ -8,8 +8,8 @@
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--font-sans--font-feature-settings: "cv11", "ss03";
--font-mono:
- var(--font-ioskeley-mono), ui-monospace, SFMono-Regular, "SF Mono",
- Menlo, Consolas, monospace;
+ var(--font-ioskeley-mono), ui-monospace, SFMono-Regular, "SF Mono", Menlo,
+ Consolas, monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-sidebar-ring: var(--sidebar-ring);
@@ -76,6 +76,7 @@
@theme {
--animate-shimmer: shimmer 1.4s ease-in-out infinite;
+ --animate-caret-blink: caret-blink 1.2s ease-out infinite;
@keyframes shimmer {
0% {
@@ -85,6 +86,18 @@
transform: translateX(300%);
}
}
+
+ @keyframes caret-blink {
+ 0%,
+ 70%,
+ 100% {
+ opacity: 1;
+ }
+ 20%,
+ 50% {
+ opacity: 0;
+ }
+ }
}
@layer theme {
diff --git a/web/app/two-factor/page.tsx b/web/app/two-factor/page.tsx
new file mode 100644
index 00000000..922bc945
--- /dev/null
+++ b/web/app/two-factor/page.tsx
@@ -0,0 +1,17 @@
+import { Suspense } from "react";
+import { TwoFactorChallengePage } from "@/components/auth/two-factor-challenge-page";
+import { Spinner } from "@/components/ui/spinner";
+
+export default function Page() {
+ return (
+
+
+
+ }
+ >
+
+
+ );
+}
diff --git a/web/components/auth/sign-in-page.tsx b/web/components/auth/sign-in-page.tsx
index 5e3da5a5..bad5a027 100644
--- a/web/components/auth/sign-in-page.tsx
+++ b/web/components/auth/sign-in-page.tsx
@@ -17,12 +17,13 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { signIn, useSession } from "@/lib/auth-client";
+import { getSafeAuthRedirect } from "@/lib/two-factor";
export function SignInPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { data: session, isPending } = useSession();
- const redirectTo = searchParams.get("redirect") || "/dashboard";
+ const redirectTo = getSafeAuthRedirect(searchParams.get("redirect"));
useEffect(() => {
if (!isPending && session) {
@@ -40,15 +41,22 @@ export function SignInPage() {
setError("");
setLoading(true);
- const { error } = await signIn.email({
+ const response = await signIn.email({
email,
password,
});
setLoading(false);
- if (error) {
- setError(error.message || "Failed to sign in");
+ if (response.error) {
+ setError(response.error.message || "Failed to sign in");
+ return;
+ }
+
+ if (
+ (response.data as { twoFactorRedirect?: boolean } | null)
+ ?.twoFactorRedirect
+ ) {
return;
}
diff --git a/web/components/auth/two-factor-challenge-page.tsx b/web/components/auth/two-factor-challenge-page.tsx
new file mode 100644
index 00000000..e23ee920
--- /dev/null
+++ b/web/components/auth/two-factor-challenge-page.tsx
@@ -0,0 +1,226 @@
+"use client";
+
+import { REGEXP_ONLY_DIGITS } from "input-otp";
+import Image from "next/image";
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useEffect, useMemo, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import {
+ InputOTP,
+ InputOTPGroup,
+ InputOTPSlot,
+} from "@/components/ui/input-otp";
+import { Label } from "@/components/ui/label";
+import { Spinner } from "@/components/ui/spinner";
+import { Switch } from "@/components/ui/switch";
+import { authClient, useSession } from "@/lib/auth-client";
+import { getSafeAuthRedirect } from "@/lib/two-factor";
+
+type AuthClientError = {
+ message?: string;
+ error_description?: string;
+} | null;
+
+function getErrorMessage(error: AuthClientError, fallback: string) {
+ return error?.message || error?.error_description || fallback;
+}
+
+function normalizeCode(value: string) {
+ return value.replace(/\s/g, "");
+}
+
+export function TwoFactorChallengePage() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const { data: session, isPending } = useSession();
+ const redirectTo = getSafeAuthRedirect(searchParams.get("redirect"));
+ const [mode, setMode] = useState<"totp" | "backup">("totp");
+ const [code, setCode] = useState("");
+ const [trustDevice, setTrustDevice] = useState(false);
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+ const normalizedCode = useMemo(() => normalizeCode(code), [code]);
+
+ useEffect(() => {
+ if (!isPending && session) {
+ router.replace(redirectTo);
+ }
+ }, [isPending, redirectTo, router, session]);
+
+ async function handleSubmit(event: React.FormEvent) {
+ event.preventDefault();
+ setError("");
+ setLoading(true);
+
+ const response =
+ mode === "totp"
+ ? await authClient.twoFactor.verifyTotp({
+ code: normalizedCode,
+ trustDevice,
+ })
+ : await authClient.twoFactor.verifyBackupCode({
+ code: normalizedCode,
+ trustDevice,
+ });
+
+ setLoading(false);
+
+ if (response.error) {
+ setError(
+ getErrorMessage(
+ response.error,
+ mode === "totp"
+ ? "Invalid authenticator code"
+ : "Invalid backup code",
+ ),
+ );
+ return;
+ }
+
+ router.push(redirectTo);
+ }
+
+ if (isPending || session) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Two-Factor Authentication
+
+ Enter the code from your authenticator app to continue
+
+
+
+
+
+ );
+}
diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx
index e385f341..3931e2da 100644
--- a/web/components/service/details/service-details-overview.tsx
+++ b/web/components/service/details/service-details-overview.tsx
@@ -28,8 +28,8 @@ import { isObservedStarting } from "@/lib/deployment-status";
import { fetcher } from "@/lib/fetcher";
import { cn } from "@/lib/utils";
-type RequestStatsResponse = {
- loggingEnabled: boolean;
+type ServiceMetricsResponse = {
+ metricsEnabled: boolean;
range: string;
windowStart: string;
windowEnd: string;
@@ -40,6 +40,15 @@ type RequestStatsResponse = {
timestamp: string;
totalRequests: number;
statuses: Record;
+ p50ResponseTimeMs: number | null;
+ p90ResponseTimeMs: number | null;
+ p95ResponseTimeMs: number | null;
+ p99ResponseTimeMs: number | null;
+ ingressBytesPerSecond: number | null;
+ egressBytesPerSecond: number | null;
+ cpuUsagePercent: number | null;
+ memoryUsagePercent: number | null;
+ memoryUsedBytes: number | null;
}>;
};
@@ -85,20 +94,18 @@ type ServiceStatus = {
type ChartRow = {
timestamp: string;
totalRequests: number;
-} & Record;
+} & Record;
-type RequestChartMode = "rate" | "total";
+type ServiceChartMode = "requests" | "latency" | "traffic" | "resources";
type StatusSeries = {
status: string;
- rateDataKey: string;
totalDataKey: string;
color: string;
- averageRequestsPerSecond: number;
totalRequests: number;
};
-type RequestTooltipPayload = {
+type ServiceMetricsTooltipPayload = {
name?: string;
value?: unknown;
color?: string;
@@ -106,11 +113,23 @@ type RequestTooltipPayload = {
payload?: ChartRow;
};
-type RequestTooltipProps = {
+type ServiceMetricsTooltipProps = {
active?: boolean;
label?: string | number;
- mode: RequestChartMode;
- payload?: readonly RequestTooltipPayload[];
+ mode: ServiceChartMode;
+ payload?: readonly ServiceMetricsTooltipPayload[];
+};
+
+type MetricSeries = {
+ key: string;
+ label: string;
+ color: string;
+ valueFormatter: (value: number) => string;
+};
+
+type ServiceMetricSummaryItem = {
+ label: string;
+ value: string;
};
const STATUS_TONE_CLASSES: Record<
@@ -140,32 +159,37 @@ const STATUS_CODE_COLOR_PALETTES: Record = {
default: ["#64748b", "#0ea5e9", "#a855f7", "#71717a"],
};
+const STATUS_FAMILY_COLORS: Record = {
+ "2xx": "#10b981",
+ "3xx": "#6366f1",
+ "4xx": "#f59e0b",
+ "5xx": "#ef4444",
+ unknown: "#64748b",
+};
+
export function ServiceDetailsOverview({ service }: { service: Service }) {
const { proxyDomain } = useService();
const overview = useMemo(
() => buildOverviewData(service, proxyDomain),
[service, proxyDomain],
);
- const requestStatsUrl =
- overview.publicHttpCount > 0
- ? `/api/services/${service.id}/request-stats?range=week`
- : null;
+ const serviceMetricsUrl = `/api/services/${service.id}/metrics?range=24h`;
const {
- data: requestStats,
- error: requestStatsError,
- isLoading: isRequestStatsLoading,
- } = useSWR(requestStatsUrl, fetcher, {
+ data: serviceMetrics,
+ error: serviceMetricsError,
+ isLoading: isServiceMetricsLoading,
+ } = useSWR(serviceMetricsUrl, fetcher, {
refreshInterval: 60000,
});
return (
-
0}
- stats={requestStats}
- error={requestStatsError}
- isLoading={isRequestStatsLoading}
+ stats={serviceMetrics}
+ error={serviceMetricsError}
+ isLoading={isServiceMetricsLoading}
/>
@@ -174,23 +198,31 @@ export function ServiceDetailsOverview({ service }: { service: Service }) {
);
}
-function RequestStatsPanel({
+function ServiceMetricsPanel({
hasPublicHttp,
stats,
error,
isLoading,
}: {
hasPublicHttp: boolean;
- stats?: RequestStatsResponse;
+ stats?: ServiceMetricsResponse;
error?: unknown;
isLoading: boolean;
}) {
- const [chartMode, setChartMode] = useState("total");
+ const [chartMode, setChartMode] = useState("requests");
const chartRows = useMemo(() => buildChartRows(stats), [stats]);
const statusSeries = useMemo(() => buildStatusSeries(stats), [stats]);
- const hasChartData = chartRows.some((row) => row.totalRequests > 0);
- const isUnavailable = Boolean(error) || stats?.loggingEnabled === false;
- const hasMetricData = hasPublicHttp && stats && !isUnavailable;
+ const activeSeries = useMemo(
+ () => buildMetricSeries(chartMode, statusSeries),
+ [chartMode, statusSeries],
+ );
+ const hasChartData = hasMetricDataForMode(chartRows, chartMode, activeSeries);
+ const isUnavailable = Boolean(error) || stats?.metricsEnabled === false;
+ const hasMetricData = Boolean(stats && !isUnavailable);
+ const summaryItems = useMemo(
+ () => buildServiceMetricSummaryItems(chartMode, stats, chartRows, hasMetricData),
+ [chartMode, stats, chartRows, hasMetricData],
+ );
return (
@@ -201,61 +233,46 @@ function RequestStatsPanel({
- ) : hasPublicHttp ? (
+ ) : (
-
-
- {hasMetricData
- ? formatCompactNumber(stats.totalRequests)
- : "-"}
-
-
- requests this week
-
-
-
-
- {hasMetricData
- ? formatRate(getAverageRequestsPerSecond(stats))
- : "-"}
-
-
- avg RPS this week
-
-
+ {summaryItems.map((item) => (
+
+
+ {item.value}
+
+
{item.label}
+
+ ))}
- ) : (
- <>
-
- -
-
-
- no public HTTP ingress
-
- >
)}
- {!hasPublicHttp ? (
-
- ) : isLoading ? (
+ {isLoading ? (
) : isUnavailable ? (
-
+
) : !hasChartData ? (
-
+
) : (
@@ -289,30 +306,26 @@ function RequestStatsPanel({
+ formatAxisTick(Number(value), chartMode)
}
className="text-xs"
/>
(
-
)}
/>
- {statusSeries.map((series) => (
+ {activeSeries.map((series) => (
- {statusSeries.length > 0 && (
+ {activeSeries.length > 0 && (
- {statusSeries.map((series) => (
+ {activeSeries.map((series) => (
))}
@@ -349,18 +354,20 @@ function RequestStatsPanel({
);
}
-function RequestChartModeToggle({
+function ServiceChartModeToggle({
value,
onChange,
disabled,
}: {
- value: RequestChartMode;
- onChange: (value: RequestChartMode) => void;
+ value: ServiceChartMode;
+ onChange: (value: ServiceChartMode) => void;
disabled: boolean;
}) {
- const options: Array<{ value: RequestChartMode; label: string }> = [
- { value: "total", label: "Total" },
- { value: "rate", label: "RPS" },
+ const options: Array<{ value: ServiceChartMode; label: string }> = [
+ { value: "requests", label: "Requests" },
+ { value: "latency", label: "Latency" },
+ { value: "traffic", label: "Traffic" },
+ { value: "resources", label: "Resources" },
];
return (
@@ -616,7 +623,7 @@ function LegendMetric({
);
}
-function RequestStatsState({ message }: { message: string }) {
+function ServiceMetricsState({ message }: { message: string }) {
return (
{message}
@@ -624,16 +631,18 @@ function RequestStatsState({ message }: { message: string }) {
);
}
-function RequestStatsTooltip({
+function ServiceMetricsTooltip({
active,
payload,
label,
mode,
-}: RequestTooltipProps) {
+}: ServiceMetricsTooltipProps) {
if (!active || !payload?.length) return null;
const row = payload[0]?.payload;
- const visiblePayload = payload.filter((item) => Number(item.value) > 0);
+ const visiblePayload = payload.filter(
+ (item) => item.value != null && Number.isFinite(Number(item.value)),
+ );
const items = visiblePayload.length > 0 ? visiblePayload : payload;
return (
@@ -824,28 +833,34 @@ function getSourceInfo(service: Service): SourceInfo {
};
}
-function buildChartRows(stats?: RequestStatsResponse): ChartRow[] {
+function buildChartRows(stats?: ServiceMetricsResponse): ChartRow[] {
if (!stats) return [];
return stats.buckets.map((bucket) => {
const row: ChartRow = {
timestamp: bucket.timestamp,
totalRequests: bucket.totalRequests,
+ p50ResponseTimeMs: bucket.p50ResponseTimeMs,
+ p90ResponseTimeMs: bucket.p90ResponseTimeMs,
+ p95ResponseTimeMs: bucket.p95ResponseTimeMs,
+ p99ResponseTimeMs: bucket.p99ResponseTimeMs,
+ ingressBytesPerSecond: bucket.ingressBytesPerSecond,
+ egressBytesPerSecond: bucket.egressBytesPerSecond,
+ cpuUsagePercent: bucket.cpuUsagePercent,
+ memoryUsagePercent: bucket.memoryUsagePercent,
+ memoryUsedBytes: bucket.memoryUsedBytes,
};
for (const status of stats.statusCodes) {
const requests = bucket.statuses[status] ?? 0;
- row[getStatusRateDataKey(status)] = requests / stats.stepSeconds;
- row[getStatusTotalDataKey(status)] = requests;
+ row[getStatusDataKey(status)] = requests;
}
return row;
});
}
-function buildStatusSeries(stats?: RequestStatsResponse): StatusSeries[] {
+function buildStatusSeries(stats?: ServiceMetricsResponse): StatusSeries[] {
if (!stats) return [];
- const totalSeconds = getStatsDurationSeconds(stats);
-
return stats.statusCodes.map((status, index) => {
const totalRequests = stats.buckets.reduce(
(total, bucket) => total + (bucket.statuses[status] ?? 0),
@@ -854,21 +869,14 @@ function buildStatusSeries(stats?: RequestStatsResponse): StatusSeries[] {
return {
status,
- rateDataKey: getStatusRateDataKey(status),
- totalDataKey: getStatusTotalDataKey(status),
+ totalDataKey: getStatusDataKey(status),
color: getStatusColor(status, index),
- averageRequestsPerSecond:
- totalSeconds > 0 ? totalRequests / totalSeconds : 0,
totalRequests,
};
});
}
-function getStatusRateDataKey(status: string): string {
- return `${getStatusDataKeyBase(status)}_rate`;
-}
-
-function getStatusTotalDataKey(status: string): string {
+function getStatusDataKey(status: string): string {
return `${getStatusDataKeyBase(status)}_total`;
}
@@ -878,6 +886,9 @@ function getStatusDataKeyBase(status: string): string {
}
function getStatusColor(status: string, index: number): string {
+ const familyColor = STATUS_FAMILY_COLORS[status];
+ if (familyColor) return familyColor;
+
const palette =
STATUS_CODE_COLOR_PALETTES[status.charAt(0)] ??
STATUS_CODE_COLOR_PALETTES.default;
@@ -977,7 +988,211 @@ function formatRate(value: number): string {
return value.toFixed(2).replace(/\.?0+$/, "");
}
-function getAverageRequestsPerSecond(stats: RequestStatsResponse): number {
+function buildServiceMetricSummaryItems(
+ mode: ServiceChartMode,
+ stats: ServiceMetricsResponse | undefined,
+ rows: ChartRow[],
+ hasMetricData: boolean,
+): ServiceMetricSummaryItem[] {
+ if (mode === "requests") {
+ return [
+ {
+ label: "requests in 24h",
+ value:
+ hasMetricData && stats
+ ? formatCompactNumber(stats.totalRequests)
+ : "-",
+ },
+ {
+ label: "avg RPS",
+ value:
+ hasMetricData && stats
+ ? formatRate(getAverageRequestsPerSecond(stats))
+ : "-",
+ },
+ ];
+ }
+
+ if (mode === "latency") {
+ return [
+ {
+ label: "p50 latency",
+ value: formatNullableMetric(
+ getLatestValue(rows, "p50ResponseTimeMs"),
+ formatDurationMs,
+ ),
+ },
+ {
+ label: "p95 latency",
+ value: formatNullableMetric(
+ getLatestValue(rows, "p95ResponseTimeMs"),
+ formatDurationMs,
+ ),
+ },
+ {
+ label: "p99 latency",
+ value: formatNullableMetric(
+ getLatestValue(rows, "p99ResponseTimeMs"),
+ formatDurationMs,
+ ),
+ },
+ ];
+ }
+
+ if (mode === "traffic") {
+ return [
+ {
+ label: "ingress",
+ value: formatNullableMetric(
+ getLatestValue(rows, "ingressBytesPerSecond"),
+ (value) => `${formatBytes(value)}/s`,
+ ),
+ },
+ {
+ label: "egress",
+ value: formatNullableMetric(
+ getLatestValue(rows, "egressBytesPerSecond"),
+ (value) => `${formatBytes(value)}/s`,
+ ),
+ },
+ ];
+ }
+
+ return [
+ {
+ label: "CPU",
+ value: formatNullableMetric(
+ getLatestValue(rows, "cpuUsagePercent"),
+ (value) => `${formatRate(value)}%`,
+ ),
+ },
+ {
+ label: "memory",
+ value: formatNullableMetric(
+ getLatestValue(rows, "memoryUsagePercent"),
+ (value) => `${formatRate(value)}%`,
+ ),
+ },
+ ];
+}
+
+function formatNullableMetric(
+ value: number | null,
+ formatter: (value: number) => string,
+): string {
+ return value == null ? "-" : formatter(value);
+}
+
+function buildMetricSeries(
+ mode: ServiceChartMode,
+ statusSeries: StatusSeries[],
+): MetricSeries[] {
+ if (mode === "requests") {
+ return statusSeries.map((series) => ({
+ key: series.totalDataKey,
+ label: series.status,
+ color: series.color,
+ valueFormatter: formatRequestCount,
+ }));
+ }
+
+ if (mode === "latency") {
+ return [
+ {
+ key: "p99ResponseTimeMs",
+ label: "p99",
+ color: "#ef4444",
+ valueFormatter: formatDurationMs,
+ },
+ {
+ key: "p95ResponseTimeMs",
+ label: "p95",
+ color: "#ec4899",
+ valueFormatter: formatDurationMs,
+ },
+ {
+ key: "p90ResponseTimeMs",
+ label: "p90",
+ color: "#f59e0b",
+ valueFormatter: formatDurationMs,
+ },
+ {
+ key: "p50ResponseTimeMs",
+ label: "p50",
+ color: "#3b82f6",
+ valueFormatter: formatDurationMs,
+ },
+ ];
+ }
+
+ if (mode === "traffic") {
+ return [
+ {
+ key: "ingressBytesPerSecond",
+ label: "Ingress",
+ color: "#0ea5e9",
+ valueFormatter: (value) => `${formatBytes(value)}/s`,
+ },
+ {
+ key: "egressBytesPerSecond",
+ label: "Egress",
+ color: "#10b981",
+ valueFormatter: (value) => `${formatBytes(value)}/s`,
+ },
+ ];
+ }
+
+ return [
+ {
+ key: "cpuUsagePercent",
+ label: "CPU",
+ color: "#8b5cf6",
+ valueFormatter: (value) => `${formatRate(value)}%`,
+ },
+ {
+ key: "memoryUsagePercent",
+ label: "Memory",
+ color: "#14b8a6",
+ valueFormatter: (value) => `${formatRate(value)}%`,
+ },
+ ];
+}
+
+function hasMetricDataForMode(
+ rows: ChartRow[],
+ mode: ServiceChartMode,
+ series: MetricSeries[],
+): boolean {
+ if (mode === "requests") {
+ return rows.some((row) => row.totalRequests > 0);
+ }
+ return rows.some((row) =>
+ series.some((item) => {
+ const value = row[item.key];
+ return typeof value === "number" && Number.isFinite(value);
+ }),
+ );
+}
+
+function getLatestValue(rows: ChartRow[], key: string): number | null {
+ for (let index = rows.length - 1; index >= 0; index--) {
+ const value = rows[index][key];
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return value;
+ }
+ }
+ return null;
+}
+
+function formatLatestSeriesValue(
+ rows: ChartRow[],
+ series: MetricSeries,
+): string {
+ const value = getLatestValue(rows, series.key);
+ return value == null ? "-" : series.valueFormatter(value);
+}
+
+function getAverageRequestsPerSecond(stats: ServiceMetricsResponse): number {
const totalSeconds = getStatsDurationSeconds(stats);
if (totalSeconds <= 0) return 0;
@@ -985,7 +1200,7 @@ function getAverageRequestsPerSecond(stats: RequestStatsResponse): number {
return stats.totalRequests / totalSeconds;
}
-function getStatsDurationSeconds(stats: RequestStatsResponse): number {
+function getStatsDurationSeconds(stats: ServiceMetricsResponse): number {
const windowStart = new Date(stats.windowStart).getTime();
const windowEnd = new Date(stats.windowEnd).getTime();
@@ -1000,18 +1215,34 @@ function getStatsDurationSeconds(stats: RequestStatsResponse): number {
return stats.buckets.length * stats.stepSeconds;
}
-function formatChartValue(value: number, mode: RequestChartMode): string {
- if (mode === "rate") return `${formatRate(value)}/s`;
-
+function formatChartValue(value: number, mode: ServiceChartMode): string {
+ if (!Number.isFinite(value)) return "-";
+ if (mode === "latency") return formatDurationMs(value);
+ if (mode === "traffic") return `${formatBytes(value)}/s`;
+ if (mode === "resources") return `${formatRate(value)}%`;
return formatRequestCount(value);
}
+function formatAxisTick(value: number, mode: ServiceChartMode): string {
+ if (mode === "latency") return formatDurationMs(value);
+ if (mode === "traffic") return formatBytes(value);
+ if (mode === "resources") return `${formatRateTick(value)}%`;
+ return formatRequestTick(value);
+}
+
function formatRateTick(value: number): string {
if (value >= 100) return value.toFixed(0);
if (value >= 10) return value.toFixed(0);
return value.toFixed(1);
}
+function getYAxisMargin(mode: ServiceChartMode): number {
+ if (mode === "traffic") return -4;
+ if (mode === "latency") return -12;
+ if (mode === "resources") return -24;
+ return -20;
+}
+
function formatRequestTick(value: number): string {
return formatCompactNumber(value);
}
@@ -1024,6 +1255,29 @@ function formatRequestCount(value: number): string {
}).format(value);
}
+function formatDurationMs(value: number): string {
+ if (!Number.isFinite(value)) return "-";
+ if (value >= 1000) {
+ return `${(value / 1000).toFixed(value >= 10000 ? 0 : 1)}s`;
+ }
+ if (value >= 100) return `${value.toFixed(0)}ms`;
+ if (value >= 10) return `${value.toFixed(1)}ms`;
+ return `${value.toFixed(2).replace(/\.?0+$/, "")}ms`;
+}
+
+function formatBytes(value: number): string {
+ if (!Number.isFinite(value)) return "-";
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ let unitIndex = 0;
+ let scaled = Math.max(0, value);
+ while (scaled >= 1000 && unitIndex < units.length - 1) {
+ scaled /= 1000;
+ unitIndex++;
+ }
+ const maximumFractionDigits = scaled >= 100 || unitIndex === 0 ? 0 : 1;
+ return `${scaled.toFixed(maximumFractionDigits)} ${units[unitIndex]}`;
+}
+
function formatShortDate(value: string): string {
return new Intl.DateTimeFormat(undefined, {
month: "short",
diff --git a/web/components/settings/global-settings.tsx b/web/components/settings/global-settings.tsx
index 7b3d570b..189ef3ce 100644
--- a/web/components/settings/global-settings.tsx
+++ b/web/components/settings/global-settings.tsx
@@ -28,6 +28,7 @@ import {
import { ApiKeySettings } from "@/components/settings/api-key-settings";
import { EmailSettings } from "@/components/settings/email-settings";
import { MemberSettings } from "@/components/settings/member-settings";
+import { TwoFactorSettings } from "@/components/settings/two-factor-settings";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -323,6 +324,9 @@ export function GlobalSettings({
Email
+
+ Security
+
API Keys
@@ -537,6 +541,10 @@ export function GlobalSettings({
/>
+
+
+
+
diff --git a/web/components/settings/two-factor-settings.tsx b/web/components/settings/two-factor-settings.tsx
new file mode 100644
index 00000000..550f0a95
--- /dev/null
+++ b/web/components/settings/two-factor-settings.tsx
@@ -0,0 +1,560 @@
+"use client";
+
+import { REGEXP_ONLY_DIGITS } from "input-otp";
+import {
+ Copy,
+ KeyRound,
+ RefreshCw,
+ ShieldCheck,
+ ShieldOff,
+} from "lucide-react";
+import Image from "next/image";
+import QRCode from "qrcode";
+import { useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ InputOTP,
+ InputOTPGroup,
+ InputOTPSlot,
+} from "@/components/ui/input-otp";
+import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item";
+import { Label } from "@/components/ui/label";
+import { Spinner } from "@/components/ui/spinner";
+import { authClient, useSession } from "@/lib/auth-client";
+import { getTotpSecret } from "@/lib/two-factor";
+
+type AuthClientError = {
+ message?: string;
+ error_description?: string;
+} | null;
+
+type TwoFactorSessionUser = {
+ twoFactorEnabled?: boolean | null;
+};
+
+type PendingSetup = {
+ totpURI: string;
+ secret: string;
+ backupCodes: string[];
+};
+
+function getErrorMessage(error: AuthClientError, fallback: string) {
+ return error?.message || error?.error_description || fallback;
+}
+
+function normalizeCode(value: string) {
+ return value.replace(/\s/g, "");
+}
+
+async function copyToClipboard(label: string, value: string) {
+ try {
+ await navigator.clipboard.writeText(value);
+ toast.success(`${label} copied`);
+ } catch {
+ toast.error(`Failed to copy ${label.toLowerCase()}`);
+ }
+}
+
+function BackupCodes({
+ codes,
+ title = "Backup codes",
+}: {
+ codes: string[];
+ title?: string;
+}) {
+ if (codes.length === 0) return null;
+
+ return (
+
+
+ {title}
+
+
+ Store these codes somewhere safe. Each code can be used once if your
+ authenticator app is unavailable.
+
+
+ {codes.map((code) => (
+
+ {code}
+
+ ))}
+
+ void copyToClipboard("Backup codes", codes.join("\n"))}
+ >
+
+ Copy codes
+
+
+
+ );
+}
+
+export function TwoFactorSettings() {
+ const { data: session, refetch } = useSession();
+ const sessionUser = session?.user as TwoFactorSessionUser | undefined;
+ const [setupPassword, setSetupPassword] = useState("");
+ const [verificationCode, setVerificationCode] = useState("");
+ const [pendingSetup, setPendingSetup] = useState
(null);
+ const [setupQrCode, setSetupQrCode] = useState("");
+ const [backupPassword, setBackupPassword] = useState("");
+ const [disablePassword, setDisablePassword] = useState("");
+ const [recoveryCodes, setRecoveryCodes] = useState([]);
+ const [isStartingSetup, setIsStartingSetup] = useState(false);
+ const [isVerifying, setIsVerifying] = useState(false);
+ const [isGeneratingCodes, setIsGeneratingCodes] = useState(false);
+ const [isDisabling, setIsDisabling] = useState(false);
+
+ const isEnabled = Boolean(
+ sessionUser?.twoFactorEnabled || recoveryCodes.length,
+ );
+ const formattedVerificationCode = useMemo(
+ () => normalizeCode(verificationCode),
+ [verificationCode],
+ );
+
+ useEffect(() => {
+ if (!pendingSetup?.totpURI) {
+ setSetupQrCode("");
+ return;
+ }
+
+ let shouldUseResult = true;
+
+ QRCode.toDataURL(pendingSetup.totpURI, {
+ errorCorrectionLevel: "M",
+ margin: 2,
+ width: 192,
+ color: {
+ dark: "#000000",
+ light: "#ffffff",
+ },
+ })
+ .then((dataUrl) => {
+ if (shouldUseResult) setSetupQrCode(dataUrl);
+ })
+ .catch(() => {
+ if (shouldUseResult) setSetupQrCode("");
+ });
+
+ return () => {
+ shouldUseResult = false;
+ };
+ }, [pendingSetup?.totpURI]);
+
+ function refreshSession() {
+ return refetch({ query: { disableCookieCache: true } });
+ }
+
+ async function handleStartSetup(event: React.FormEvent) {
+ event.preventDefault();
+ setIsStartingSetup(true);
+
+ try {
+ const response = await authClient.twoFactor.enable({
+ password: setupPassword,
+ });
+
+ if (response.error || !response.data?.totpURI) {
+ throw new Error(
+ getErrorMessage(response.error, "Failed to start 2FA setup"),
+ );
+ }
+
+ setPendingSetup({
+ totpURI: response.data.totpURI,
+ secret: getTotpSecret(response.data.totpURI),
+ backupCodes: response.data.backupCodes ?? [],
+ });
+ setSetupPassword("");
+ setVerificationCode("");
+ setRecoveryCodes([]);
+ toast.success("Authenticator setup started");
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to start 2FA setup",
+ );
+ } finally {
+ setIsStartingSetup(false);
+ }
+ }
+
+ async function handleVerifySetup(event: React.FormEvent) {
+ event.preventDefault();
+ if (!pendingSetup) return;
+
+ setIsVerifying(true);
+ try {
+ const response = await authClient.twoFactor.verifyTotp({
+ code: formattedVerificationCode,
+ });
+
+ if (response.error) {
+ throw new Error(
+ getErrorMessage(
+ response.error,
+ "Failed to verify authenticator code",
+ ),
+ );
+ }
+
+ setRecoveryCodes(pendingSetup.backupCodes);
+ setPendingSetup(null);
+ setVerificationCode("");
+ await refreshSession();
+ toast.success("Two-factor authentication enabled");
+ } catch (error) {
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : "Failed to verify authenticator code",
+ );
+ } finally {
+ setIsVerifying(false);
+ }
+ }
+
+ async function handleGenerateBackupCodes(
+ event: React.FormEvent,
+ ) {
+ event.preventDefault();
+ setIsGeneratingCodes(true);
+
+ try {
+ const response = await authClient.twoFactor.generateBackupCodes({
+ password: backupPassword,
+ });
+
+ if (response.error || !response.data?.backupCodes) {
+ throw new Error(
+ getErrorMessage(response.error, "Failed to generate backup codes"),
+ );
+ }
+
+ setRecoveryCodes(response.data.backupCodes);
+ setBackupPassword("");
+ toast.success("Backup codes regenerated");
+ } catch (error) {
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : "Failed to generate backup codes",
+ );
+ } finally {
+ setIsGeneratingCodes(false);
+ }
+ }
+
+ async function handleDisable(event: React.FormEvent) {
+ event.preventDefault();
+ setIsDisabling(true);
+
+ try {
+ const response = await authClient.twoFactor.disable({
+ password: disablePassword,
+ });
+
+ if (response.error || response.data?.status !== true) {
+ throw new Error(
+ getErrorMessage(response.error, "Failed to disable 2FA"),
+ );
+ }
+
+ setDisablePassword("");
+ setBackupPassword("");
+ setRecoveryCodes([]);
+ setPendingSetup(null);
+ await refreshSession();
+ toast.success("Two-factor authentication disabled");
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to disable 2FA",
+ );
+ } finally {
+ setIsDisabling(false);
+ }
+ }
+
+ return (
+
+
-
+
+
+
+
+
+ Two-Factor Authentication
+
+ {isEnabled ? "Enabled" : "Off"}
+
+
+
+ Use an authenticator app to protect your account with a 6-digit
+ code.
+
+
+
+
+
+ {pendingSetup ? (
+
+
+
+
Set up your app
+
+ Scan the QR code or add the setup key to your authenticator
+ app, then enter the current 6-digit code.
+
+
+
+
+
+
+ {setupQrCode ? (
+
+ ) : (
+
+ )}
+
+
+
Scan QR code
+
+ Use Google Authenticator, 1Password, Authy, or another
+ TOTP-compatible app.
+
+
+
+
+
Setup key
+
+
+
+ void copyToClipboard("Setup key", pendingSetup.secret)
+ }
+ >
+
+ Copy
+
+
+
+
+
Authenticator URI
+
+
+
+ void copyToClipboard(
+ "Authenticator URI",
+ pendingSetup.totpURI,
+ )
+ }
+ >
+
+ Copy
+
+
+
+
+
+
+
+
+ ) : isEnabled ? (
+
+
+
+ 2FA is enabled
+
+ Your next password sign-in will require a code from your
+ authenticator app unless this device is trusted.
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/web/components/ui/input-otp.tsx b/web/components/ui/input-otp.tsx
new file mode 100644
index 00000000..c2258750
--- /dev/null
+++ b/web/components/ui/input-otp.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import { OTPInput, OTPInputContext } from "input-otp";
+import { MinusIcon } from "lucide-react";
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+function InputOTP({
+ className,
+ containerClassName,
+ ...props
+}: React.ComponentProps & {
+ containerClassName?: string;
+}) {
+ return (
+
+ );
+}
+
+function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function InputOTPSlot({
+ index,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ index: number;
+}) {
+ const inputOTPContext = React.useContext(OTPInputContext);
+ const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
+
+ return (
+
+ {char}
+ {hasFakeCaret && (
+
+ )}
+
+ );
+}
+
+function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
+ return (
+
+
+
+ );
+}
+
+export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
diff --git a/web/db/schema.ts b/web/db/schema.ts
index 2a9137a4..86da744c 100644
--- a/web/db/schema.ts
+++ b/web/db/schema.ts
@@ -18,6 +18,7 @@ export const user = pgTable("user", {
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
+ twoFactorEnabled: boolean("two_factor_enabled").default(false).notNull(),
role: text("role", { enum: ["admin", "developer", "reader"] })
.notNull()
.default("reader"),
@@ -91,6 +92,27 @@ export const verification = pgTable(
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
+export const twoFactor = pgTable(
+ "twoFactor",
+ {
+ id: text("id").primaryKey(),
+ secret: text("secret").notNull(),
+ backupCodes: text("backup_codes").notNull(),
+ userId: text("user_id")
+ .notNull()
+ .references(() => user.id, { onDelete: "cascade" }),
+ verified: boolean("verified").default(true),
+ failedVerificationCount: integer("failed_verification_count")
+ .default(0)
+ .notNull(),
+ lockedUntil: timestamp("locked_until"),
+ },
+ (table) => [
+ index("twoFactor_secret_idx").on(table.secret),
+ index("twoFactor_userId_idx").on(table.userId),
+ ],
+);
+
export const deviceCode = pgTable(
"deviceCode",
{
@@ -187,11 +209,12 @@ export const memberInvitations = pgTable(
],
);
-export const userRelations = relations(user, ({ many }) => ({
+export const userRelations = relations(user, ({ many, one }) => ({
sessions: many(session),
accounts: many(account),
apiKeys: many(apikey),
deviceCodes: many(deviceCode),
+ twoFactor: one(twoFactor),
sentMemberInvitations: many(memberInvitations, {
relationName: "sentMemberInvitations",
}),
@@ -214,6 +237,13 @@ export const accountRelations = relations(account, ({ one }) => ({
}),
}));
+export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
+ user: one(user, {
+ fields: [twoFactor.userId],
+ references: [user.id],
+ }),
+}));
+
export const deviceCodeRelations = relations(deviceCode, ({ one }) => ({
user: one(user, {
fields: [deviceCode.userId],
diff --git a/web/lib/auth-client.ts b/web/lib/auth-client.ts
index 87dcaf42..9ec15831 100644
--- a/web/lib/auth-client.ts
+++ b/web/lib/auth-client.ts
@@ -1,9 +1,32 @@
import { apiKeyClient } from "@better-auth/api-key/client";
-import { deviceAuthorizationClient } from "better-auth/client/plugins";
+import {
+ deviceAuthorizationClient,
+ twoFactorClient,
+} from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
+import { getSafeAuthRedirect } from "@/lib/two-factor";
+
+function getTwoFactorRedirectUrl() {
+ if (typeof window === "undefined") return "/two-factor";
+
+ const redirectTo = getSafeAuthRedirect(
+ new URLSearchParams(window.location.search).get("redirect"),
+ );
+ const target = new URL("/two-factor", window.location.origin);
+ target.searchParams.set("redirect", redirectTo);
+ return target.toString();
+}
export const authClient = createAuthClient({
- plugins: [apiKeyClient(), deviceAuthorizationClient()],
+ plugins: [
+ apiKeyClient(),
+ deviceAuthorizationClient(),
+ twoFactorClient({
+ onTwoFactorRedirect: () => {
+ window.location.href = getTwoFactorRedirectUrl();
+ },
+ }),
+ ],
});
export const { signIn, signUp, signOut, useSession } = authClient;
diff --git a/web/lib/auth.ts b/web/lib/auth.ts
index 1e373fc0..73bc27fb 100644
--- a/web/lib/auth.ts
+++ b/web/lib/auth.ts
@@ -1,7 +1,7 @@
import { apiKey } from "@better-auth/api-key";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
-import { bearer, deviceAuthorization } from "better-auth/plugins";
+import { bearer, deviceAuthorization, twoFactor } from "better-auth/plugins";
import { admin } from "better-auth/plugins/admin";
import { userAc } from "better-auth/plugins/admin/access";
import { headers } from "next/headers";
@@ -11,8 +11,10 @@ import type { MemberRole } from "@/db/types";
import { getUserRole, hasAnyRole } from "@/lib/members";
const TECHULUS_CLI_CLIENT_ID = "techulus-cli";
+const APP_NAME = "Techulus Cloud";
export const auth = betterAuth({
+ appName: APP_NAME,
database: drizzleAdapter(db, {
provider: "pg",
schema,
@@ -47,6 +49,9 @@ export const auth = betterAuth({
reader: userAc,
},
}),
+ twoFactor({
+ issuer: APP_NAME,
+ }),
bearer(),
],
});
diff --git a/web/lib/two-factor.ts b/web/lib/two-factor.ts
new file mode 100644
index 00000000..c45e0502
--- /dev/null
+++ b/web/lib/two-factor.ts
@@ -0,0 +1,39 @@
+export const DEFAULT_AUTH_REDIRECT = "/dashboard";
+
+const AUTH_REDIRECT_ORIGIN = "https://auth.local";
+
+function hasUnsafeAuthRedirectCharacter(value: string) {
+ return Array.from(value).some((character) => {
+ const charCode = character.charCodeAt(0);
+ return character === "\\" || charCode <= 31 || charCode === 127;
+ });
+}
+
+export function getSafeAuthRedirect(value: string | null | undefined) {
+ if (!value) return DEFAULT_AUTH_REDIRECT;
+ if (
+ value !== value.trim() ||
+ hasUnsafeAuthRedirectCharacter(value) ||
+ !value.startsWith("/") ||
+ value.startsWith("//")
+ ) {
+ return DEFAULT_AUTH_REDIRECT;
+ }
+
+ try {
+ const url = new URL(value, AUTH_REDIRECT_ORIGIN);
+ if (url.origin !== AUTH_REDIRECT_ORIGIN) return DEFAULT_AUTH_REDIRECT;
+
+ return `${url.pathname}${url.search}${url.hash}`;
+ } catch {
+ return DEFAULT_AUTH_REDIRECT;
+ }
+}
+
+export function getTotpSecret(totpURI: string) {
+ try {
+ return new URL(totpURI).searchParams.get("secret") ?? "";
+ } catch {
+ return "";
+ }
+}
diff --git a/web/lib/victoria-logs.ts b/web/lib/victoria-logs.ts
index 32484641..9540568d 100644
--- a/web/lib/victoria-logs.ts
+++ b/web/lib/victoria-logs.ts
@@ -1,35 +1,6 @@
const VICTORIA_LOGS_URL = process.env.VICTORIA_LOGS_URL;
const VICTORIA_LOGS_PRIVATE_URL = process.env.VICTORIA_LOGS_PRIVATE_URL;
-export const REQUEST_STATS_RANGE_OPTIONS = {
- "1h": { durationMs: 60 * 60 * 1000, stepSeconds: 60, stepLogSql: "1m" },
- "6h": {
- durationMs: 6 * 60 * 60 * 1000,
- stepSeconds: 5 * 60,
- stepLogSql: "5m",
- },
- "24h": {
- durationMs: 24 * 60 * 60 * 1000,
- stepSeconds: 5 * 60,
- stepLogSql: "5m",
- },
- "7d": {
- durationMs: 7 * 24 * 60 * 60 * 1000,
- stepSeconds: 30 * 60,
- stepLogSql: "30m",
- },
- week: {
- durationMs: 0,
- stepSeconds: 30 * 60,
- stepLogSql: "30m",
- weekToDate: true,
- },
-} as const;
-
-export type RequestStatsRange = keyof typeof REQUEST_STATS_RANGE_OPTIONS;
-
-export const DEFAULT_REQUEST_STATS_RANGE: RequestStatsRange = "7d";
-
type EndpointConfig = {
url: string;
username?: string;
@@ -84,260 +55,6 @@ export function isLoggingEnabled(): boolean {
return !!(VICTORIA_LOGS_PRIVATE_URL || VICTORIA_LOGS_URL);
}
-export type HttpRequestStatsBucket = {
- timestamp: string;
- totalRequests: number;
- statuses: Record;
-};
-
-export type HttpRequestStats = {
- range: RequestStatsRange;
- windowStart: string;
- windowEnd: string;
- stepSeconds: number;
- totalRequests: number;
- statusCodes: string[];
- buckets: HttpRequestStatsBucket[];
-};
-
-type RequestStatsRow = Record;
-
-export function parseRequestStatsRange(
- value: string | null | undefined,
-): RequestStatsRange {
- if (value && value in REQUEST_STATS_RANGE_OPTIONS) {
- return value as RequestStatsRange;
- }
- return DEFAULT_REQUEST_STATS_RANGE;
-}
-
-export function createEmptyHttpRequestStats(
- range: RequestStatsRange = DEFAULT_REQUEST_STATS_RANGE,
- now = new Date(),
-): HttpRequestStats {
- const window = getRequestStatsWindow(range, now);
- return {
- range,
- windowStart: window.start.toISOString(),
- windowEnd: window.end.toISOString(),
- stepSeconds: window.stepSeconds,
- totalRequests: 0,
- statusCodes: [],
- buckets: [],
- };
-}
-
-export function getRequestStatsWindow(
- range: RequestStatsRange,
- now = new Date(),
-): {
- start: Date;
- end: Date;
- durationMs: number;
- stepSeconds: number;
- stepLogSql: string;
-} {
- const config = REQUEST_STATS_RANGE_OPTIONS[range];
- const start =
- "weekToDate" in config && config.weekToDate
- ? getStartOfUtcWeek(now)
- : new Date(now.getTime() - config.durationMs);
-
- return {
- start,
- end: now,
- durationMs: Math.max(0, now.getTime() - start.getTime()),
- stepSeconds: config.stepSeconds,
- stepLogSql: config.stepLogSql,
- };
-}
-
-export function parseRequestStatsRows(text: string): RequestStatsRow[] {
- const lines = text.trim().split("\n").filter(Boolean);
- const rows: RequestStatsRow[] = [];
-
- for (const line of lines) {
- try {
- const parsed = JSON.parse(line) as unknown;
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
- rows.push(parsed as RequestStatsRow);
- }
- } catch {
- // VictoriaLogs should return JSON lines, but a single malformed row should
- // not break the service overview card.
- }
- }
-
- return rows;
-}
-
-export function buildRequestStatsBuckets(
- rows: RequestStatsRow[],
- options: { start: Date; end: Date; stepSeconds: number },
-): HttpRequestStatsBucket[] {
- const stepMs = options.stepSeconds * 1000;
- const startMs = floorToStep(options.start.getTime(), stepMs);
- const endMs = floorToStep(options.end.getTime(), stepMs);
-
- if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || startMs > endMs) {
- return [];
- }
-
- const byBucket = new Map<
- number,
- { totalRequests: number; statuses: Record }
- >();
- for (const row of rows) {
- const timestamp = parseTimestamp(row._time ?? row.time ?? row.timestamp);
- if (!timestamp) continue;
-
- const bucketMs = floorToStep(timestamp.getTime(), stepMs);
- const bucket = byBucket.get(bucketMs) ?? {
- totalRequests: 0,
- statuses: {},
- };
- const requests = parseStatNumber(row.requests);
- const status = normalizeStatus(row.status);
- bucket.totalRequests += requests;
- bucket.statuses[status] = (bucket.statuses[status] ?? 0) + requests;
- byBucket.set(bucketMs, bucket);
- }
-
- const buckets: HttpRequestStatsBucket[] = [];
- for (let timestampMs = startMs; timestampMs <= endMs; timestampMs += stepMs) {
- const bucket = byBucket.get(timestampMs) ?? {
- totalRequests: 0,
- statuses: {},
- };
- buckets.push({
- timestamp: new Date(timestampMs).toISOString(),
- totalRequests: bucket.totalRequests,
- statuses: bucket.statuses,
- });
- }
-
- return buckets;
-}
-
-export async function queryHttpRequestStats(options: {
- serviceId: string;
- range: RequestStatsRange;
- now?: Date;
-}): Promise {
- const now = options.now ?? new Date();
- const window = getRequestStatsWindow(options.range, now);
- const statusCodeLimit = 64;
- const bucketLimit =
- (Math.ceil(window.durationMs / (window.stepSeconds * 1000)) + 5) *
- statusCodeLimit;
- const baseFilter = [
- formatLogSqlExactFilter("service_id", options.serviceId),
- "log_type:http",
- formatLogSqlTimeRange(window.start, window.end),
- ].join(" ");
- const bucketQuery = `${baseFilter} | stats by (_time:${window.stepLogSql}, status) count() as requests | sort by (_time)`;
-
- const bucketText = await queryLogSql(bucketQuery, bucketLimit);
-
- const buckets = buildRequestStatsBuckets(parseRequestStatsRows(bucketText), {
- start: window.start,
- end: window.end,
- stepSeconds: window.stepSeconds,
- });
- const statusCodes = sortStatusCodes([
- ...buckets.flatMap((bucket) => Object.keys(bucket.statuses)),
- ]);
-
- return {
- range: options.range,
- windowStart: window.start.toISOString(),
- windowEnd: window.end.toISOString(),
- stepSeconds: window.stepSeconds,
- totalRequests: buckets.reduce(
- (sum, bucket) => sum + bucket.totalRequests,
- 0,
- ),
- statusCodes,
- buckets,
- };
-}
-
-async function queryLogSql(query: string, limit: number): Promise {
- const endpoint = getQueryEndpoint();
- if (!endpoint) {
- throw new Error("VICTORIA_LOGS_URL is not configured");
- }
-
- const url = new URL(`${endpoint.url}/select/logsql/query`);
- url.searchParams.set("query", query);
- url.searchParams.set("limit", String(limit));
-
- const response = await fetch(url.toString(), buildFetchOptions(endpoint));
-
- if (!response.ok) {
- throw new Error(
- `Failed to query logs: ${response.status} ${response.statusText}`,
- );
- }
-
- return response.text();
-}
-
-function parseStatNumber(value: unknown): number {
- if (typeof value === "number" && Number.isFinite(value)) {
- return value;
- }
- if (typeof value === "string" && value.trim() !== "") {
- const parsed = Number(value);
- return Number.isFinite(parsed) ? parsed : 0;
- }
- return 0;
-}
-
-function normalizeStatus(value: unknown): string {
- if (typeof value === "number" && Number.isFinite(value)) {
- return String(value);
- }
- if (typeof value === "string" && value.trim() !== "") {
- return value.trim();
- }
- return "unknown";
-}
-
-function sortStatusCodes(statuses: string[]): string[] {
- return Array.from(new Set(statuses)).sort((a, b) => {
- const statusA = Number(a);
- const statusB = Number(b);
- if (Number.isFinite(statusA) && Number.isFinite(statusB)) {
- return statusA - statusB;
- }
- return a.localeCompare(b);
- });
-}
-
-function parseTimestamp(value: unknown): Date | null {
- if (typeof value !== "string" && typeof value !== "number") {
- return null;
- }
-
- const timestamp = new Date(value);
- return Number.isFinite(timestamp.getTime()) ? timestamp : null;
-}
-
-function floorToStep(timestampMs: number, stepMs: number): number {
- return Math.floor(timestampMs / stepMs) * stepMs;
-}
-
-function getStartOfUtcWeek(value: Date): Date {
- const start = new Date(
- Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()),
- );
- const day = start.getUTCDay();
- const daysSinceMonday = day === 0 ? 6 : day - 1;
- start.setUTCDate(start.getUTCDate() - daysSinceMonday);
- return start;
-}
-
function formatLogSqlExactFilter(field: string, value: string): string {
const trimmed = value.trim();
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
@@ -347,10 +64,6 @@ function formatLogSqlExactFilter(field: string, value: string): string {
return `${field}:${trimmed}`;
}
-function formatLogSqlTimeRange(start: Date, end: Date): string {
- return `_time:[${start.toISOString()}, ${end.toISOString()})`;
-}
-
type QueryLogsByServiceOptions = {
serviceId: string;
limit: number;
diff --git a/web/lib/victoria-metrics.ts b/web/lib/victoria-metrics.ts
index 23984e7f..806a8717 100644
--- a/web/lib/victoria-metrics.ts
+++ b/web/lib/victoria-metrics.ts
@@ -6,8 +6,6 @@ import {
export { METRIC_RANGE_OPTIONS, type MetricRange, parseMetricRange };
-const VICTORIA_METRICS_URL = process.env.VICTORIA_METRICS_URL;
-const VICTORIA_METRICS_PRIVATE_URL = process.env.VICTORIA_METRICS_PRIVATE_URL;
let hasWarnedMissingMetricsConfig = false;
type EndpointConfig = {
@@ -27,13 +25,15 @@ type VictoriaInstantResponse = {
error?: string;
};
+type VictoriaMatrixResult = {
+ metric: Record;
+ values: Array<[number, string]>;
+};
+
type VictoriaMatrixResponse = {
status: string;
data?: {
- result?: Array<{
- metric: Record;
- values: Array<[number, string]>;
- }>;
+ result?: VictoriaMatrixResult[];
};
error?: string;
};
@@ -51,6 +51,32 @@ export type NodeMetricPoint = {
value: number;
};
+export type ServiceMetricsBucket = {
+ timestamp: string;
+ totalRequests: number;
+ statuses: Record;
+ p50ResponseTimeMs: number | null;
+ p90ResponseTimeMs: number | null;
+ p95ResponseTimeMs: number | null;
+ p99ResponseTimeMs: number | null;
+ ingressBytesPerSecond: number | null;
+ egressBytesPerSecond: number | null;
+ cpuUsagePercent: number | null;
+ memoryUsagePercent: number | null;
+ memoryUsedBytes: number | null;
+};
+
+export type ServiceMetrics = {
+ metricsEnabled: boolean;
+ range: MetricRange;
+ windowStart: string;
+ windowEnd: string;
+ stepSeconds: number;
+ totalRequests: number;
+ statusCodes: string[];
+ buckets: ServiceMetricsBucket[];
+};
+
export type NodeMetricsHistory = {
cpuUsagePercent: NodeMetricPoint[];
memoryUsagePercent: NodeMetricPoint[];
@@ -85,7 +111,9 @@ function parseEndpoint(endpoint: string): EndpointConfig {
}
function getQueryEndpoint(): EndpointConfig | undefined {
- const endpoint = VICTORIA_METRICS_PRIVATE_URL || VICTORIA_METRICS_URL;
+ const endpoint =
+ process.env.VICTORIA_METRICS_PRIVATE_URL ||
+ process.env.VICTORIA_METRICS_URL;
if (!endpoint) return undefined;
return parseEndpoint(endpoint);
}
@@ -101,7 +129,9 @@ function buildFetchOptions(config: EndpointConfig): RequestInit {
}
export function isMetricsEnabled(): boolean {
- return !!(VICTORIA_METRICS_PRIVATE_URL || VICTORIA_METRICS_URL);
+ return !!(
+ process.env.VICTORIA_METRICS_PRIVATE_URL || process.env.VICTORIA_METRICS_URL
+ );
}
export function warnMissingMetricsConfig(context: string) {
@@ -262,6 +292,180 @@ export async function queryServersMetricsHistory(options: {
}));
}
+export function createEmptyServiceMetrics(
+ range: MetricRange,
+ now = new Date(),
+): ServiceMetrics {
+ const window = getMetricWindow(range, now);
+ return {
+ metricsEnabled: false,
+ range,
+ windowStart: window.start.toISOString(),
+ windowEnd: window.end.toISOString(),
+ stepSeconds: window.stepSeconds,
+ totalRequests: 0,
+ statusCodes: [],
+ buckets: [],
+ };
+}
+
+export async function queryServiceMetrics(options: {
+ serviceId: string;
+ range: MetricRange;
+ now?: Date;
+}): Promise {
+ const endpoint = getQueryEndpoint();
+ const now = options.now ?? new Date();
+ const window = getMetricWindow(options.range, now);
+ if (!endpoint) return createEmptyServiceMetrics(options.range, now);
+
+ const serviceMatcher = buildTraefikServiceMatcher(options.serviceId);
+ const serviceId = escapePromQL(options.serviceId);
+ const rangeWindow = formatPromDuration(window.stepSeconds);
+ const traefikFilter = `service=~"${serviceMatcher}"`;
+ const queryStart = new Date(
+ window.start.getTime() + window.stepSeconds * 1000,
+ );
+
+ const [
+ requestResults,
+ p50Results,
+ p90Results,
+ p95Results,
+ p99Results,
+ ingressResults,
+ egressResults,
+ cpuResults,
+ memoryPercentResults,
+ memoryBytesResults,
+ ] = await Promise.all([
+ queryRangePromQL(endpoint, {
+ query: `sum by (code) (increase(traefik_service_requests_total{${traefikFilter}}[${rangeWindow}]))`,
+ start: queryStart,
+ end: window.end,
+ stepSeconds: window.stepSeconds,
+ }).catch(() => []),
+ queryRangePromQL(endpoint, {
+ query: responseTimeQuery(0.5, traefikFilter, rangeWindow),
+ start: queryStart,
+ end: window.end,
+ stepSeconds: window.stepSeconds,
+ }).catch(() => []),
+ queryRangePromQL(endpoint, {
+ query: responseTimeQuery(0.9, traefikFilter, rangeWindow),
+ start: queryStart,
+ end: window.end,
+ stepSeconds: window.stepSeconds,
+ }).catch(() => []),
+ queryRangePromQL(endpoint, {
+ query: responseTimeQuery(0.95, traefikFilter, rangeWindow),
+ start: queryStart,
+ end: window.end,
+ stepSeconds: window.stepSeconds,
+ }).catch(() => []),
+ queryRangePromQL(endpoint, {
+ query: responseTimeQuery(0.99, traefikFilter, rangeWindow),
+ start: queryStart,
+ end: window.end,
+ stepSeconds: window.stepSeconds,
+ }).catch(() => []),
+ queryRangePromQL(endpoint, {
+ query: `sum(rate(traefik_service_requests_bytes_total{${traefikFilter}}[${rangeWindow}]))`,
+ start: queryStart,
+ end: window.end,
+ stepSeconds: window.stepSeconds,
+ }).catch(() => []),
+ queryRangePromQL(endpoint, {
+ query: `sum(rate(traefik_service_responses_bytes_total{${traefikFilter}}[${rangeWindow}]))`,
+ start: queryStart,
+ end: window.end,
+ stepSeconds: window.stepSeconds,
+ }).catch(() => []),
+ queryRangePromQL(endpoint, {
+ query: `sum(avg_over_time(techulus_service_cpu_usage_percent{service_id="${serviceId}"}[${rangeWindow}]))`,
+ start: queryStart,
+ end: window.end,
+ stepSeconds: window.stepSeconds,
+ }).catch(() => []),
+ queryRangePromQL(endpoint, {
+ query: `sum(avg_over_time(techulus_service_memory_usage_percent{service_id="${serviceId}"}[${rangeWindow}]))`,
+ start: queryStart,
+ end: window.end,
+ stepSeconds: window.stepSeconds,
+ }).catch(() => []),
+ queryRangePromQL(endpoint, {
+ query: `sum(avg_over_time(techulus_service_memory_used_bytes{service_id="${serviceId}"}[${rangeWindow}]))`,
+ start: queryStart,
+ end: window.end,
+ stepSeconds: window.stepSeconds,
+ }).catch(() => []),
+ ]);
+
+ const buckets = createServiceMetricBuckets(window);
+ const bucketsByTimestamp = new Map(
+ buckets.map((bucket) => [bucket.timestamp, bucket]),
+ );
+ const statusCodes = new Set();
+
+ for (const result of requestResults) {
+ const status = normalizeHTTPStatusFamily(result.metric.code);
+ statusCodes.add(status);
+ for (const point of matrixResultToPoints(result)) {
+ const bucket = bucketsByTimestamp.get(point.timestamp);
+ if (!bucket) continue;
+ const requests = Math.max(0, Math.round(point.value));
+ bucket.statuses[status] = (bucket.statuses[status] ?? 0) + requests;
+ bucket.totalRequests += requests;
+ }
+ }
+
+ applySingleSeries(bucketsByTimestamp, p50Results, (bucket, value) => {
+ bucket.p50ResponseTimeMs = value * 1000;
+ });
+ applySingleSeries(bucketsByTimestamp, p90Results, (bucket, value) => {
+ bucket.p90ResponseTimeMs = value * 1000;
+ });
+ applySingleSeries(bucketsByTimestamp, p95Results, (bucket, value) => {
+ bucket.p95ResponseTimeMs = value * 1000;
+ });
+ applySingleSeries(bucketsByTimestamp, p99Results, (bucket, value) => {
+ bucket.p99ResponseTimeMs = value * 1000;
+ });
+ applySingleSeries(bucketsByTimestamp, ingressResults, (bucket, value) => {
+ bucket.ingressBytesPerSecond = value;
+ });
+ applySingleSeries(bucketsByTimestamp, egressResults, (bucket, value) => {
+ bucket.egressBytesPerSecond = value;
+ });
+ applySingleSeries(bucketsByTimestamp, cpuResults, (bucket, value) => {
+ bucket.cpuUsagePercent = value;
+ });
+ applySingleSeries(
+ bucketsByTimestamp,
+ memoryPercentResults,
+ (bucket, value) => {
+ bucket.memoryUsagePercent = value;
+ },
+ );
+ applySingleSeries(bucketsByTimestamp, memoryBytesResults, (bucket, value) => {
+ bucket.memoryUsedBytes = value;
+ });
+
+ return {
+ metricsEnabled: true,
+ range: options.range,
+ windowStart: window.start.toISOString(),
+ windowEnd: window.end.toISOString(),
+ stepSeconds: window.stepSeconds,
+ totalRequests: buckets.reduce(
+ (total, bucket) => total + bucket.totalRequests,
+ 0,
+ ),
+ statusCodes: sortStatusCodes([...statusCodes]),
+ buckets,
+ };
+}
+
async function queryInstantMetric(
endpoint: EndpointConfig,
metricName: string,
@@ -414,6 +618,39 @@ async function queryRangeMetricGroup(
return byServer;
}
+async function queryRangePromQL(
+ endpoint: EndpointConfig,
+ options: {
+ query: string;
+ start: Date;
+ end: Date;
+ stepSeconds: number;
+ },
+): Promise {
+ const url = new URL(`${endpoint.url}/api/v1/query_range`);
+ url.searchParams.set("query", options.query);
+ url.searchParams.set(
+ "start",
+ String(Math.floor(options.start.getTime() / 1000)),
+ );
+ url.searchParams.set("end", String(Math.floor(options.end.getTime() / 1000)));
+ url.searchParams.set("step", String(options.stepSeconds));
+
+ const response = await fetch(url.toString(), buildFetchOptions(endpoint));
+ if (!response.ok) {
+ throw new Error(
+ `Failed to query metrics range: ${response.status} ${response.statusText}`,
+ );
+ }
+
+ const data = (await response.json()) as VictoriaMatrixResponse;
+ if (data.status !== "success") {
+ throw new Error(data.error || "Failed to query metrics range");
+ }
+
+ return data.data?.result ?? [];
+}
+
export function emptyHistory(): NodeMetricsHistory {
return {
cpuUsagePercent: [],
@@ -424,9 +661,140 @@ export function emptyHistory(): NodeMetricsHistory {
};
}
+export function getMetricWindow(
+ range: MetricRange,
+ now = new Date(),
+): {
+ start: Date;
+ end: Date;
+ durationMs: number;
+ stepSeconds: number;
+} {
+ const option = METRIC_RANGE_OPTIONS[range];
+ const stepMs = option.stepSeconds * 1000;
+ const end = new Date(Math.floor(now.getTime() / stepMs) * stepMs);
+ const start = new Date(end.getTime() - option.durationMs);
+ return {
+ start,
+ end,
+ durationMs: option.durationMs,
+ stepSeconds: option.stepSeconds,
+ };
+}
+
+export function buildTraefikServiceMatcher(serviceId: string): string {
+ return `^${escapePromRegex(serviceId)}(@file)?$`;
+}
+
+export function formatPromDuration(seconds: number): string {
+ if (seconds % 86400 === 0) return `${seconds / 86400}d`;
+ if (seconds % 3600 === 0) return `${seconds / 3600}h`;
+ if (seconds % 60 === 0) return `${seconds / 60}m`;
+ return `${seconds}s`;
+}
+
+function responseTimeQuery(
+ quantile: number,
+ traefikFilter: string,
+ rangeWindow: string,
+) {
+ return `histogram_quantile(${quantile}, sum by (le) (rate(traefik_service_request_duration_seconds_bucket{${traefikFilter}}[${rangeWindow}])))`;
+}
+
+function createServiceMetricBuckets(window: {
+ start: Date;
+ end: Date;
+ stepSeconds: number;
+}): ServiceMetricsBucket[] {
+ const buckets: ServiceMetricsBucket[] = [];
+ const stepMs = window.stepSeconds * 1000;
+ for (
+ let timestampMs = window.start.getTime() + stepMs;
+ timestampMs <= window.end.getTime();
+ timestampMs += stepMs
+ ) {
+ buckets.push({
+ timestamp: new Date(timestampMs).toISOString(),
+ totalRequests: 0,
+ statuses: {},
+ p50ResponseTimeMs: null,
+ p90ResponseTimeMs: null,
+ p95ResponseTimeMs: null,
+ p99ResponseTimeMs: null,
+ ingressBytesPerSecond: null,
+ egressBytesPerSecond: null,
+ cpuUsagePercent: null,
+ memoryUsagePercent: null,
+ memoryUsedBytes: null,
+ });
+ }
+ return buckets;
+}
+
+function applySingleSeries(
+ bucketsByTimestamp: Map,
+ results: VictoriaMatrixResult[],
+ apply: (bucket: ServiceMetricsBucket, value: number) => void,
+) {
+ const result = results[0];
+ if (!result) return;
+ for (const point of matrixResultToPoints(result)) {
+ const bucket = bucketsByTimestamp.get(point.timestamp);
+ if (!bucket) continue;
+ apply(bucket, point.value);
+ }
+}
+
+function matrixResultToPoints(result: {
+ values: Array<[number, string]>;
+}): NodeMetricPoint[] {
+ return result.values
+ .map(([timestamp, rawValue]) => ({
+ timestamp: new Date(timestamp * 1000).toISOString(),
+ value: Number.parseFloat(rawValue),
+ }))
+ .filter((point) => Number.isFinite(point.value));
+}
+
+function sortStatusCodes(statuses: string[]): string[] {
+ const statusOrder = new Map([
+ ["2xx", 0],
+ ["3xx", 1],
+ ["4xx", 2],
+ ["5xx", 3],
+ ["unknown", 4],
+ ]);
+
+ return Array.from(new Set(statuses)).sort((a, b) => {
+ const orderA = statusOrder.get(a);
+ const orderB = statusOrder.get(b);
+ if (orderA !== undefined && orderB !== undefined) {
+ return orderA - orderB;
+ }
+ if (orderA !== undefined) return -1;
+ if (orderB !== undefined) return 1;
+
+ const statusA = Number(a);
+ const statusB = Number(b);
+ if (Number.isFinite(statusA) && Number.isFinite(statusB)) {
+ return statusA - statusB;
+ }
+ return a.localeCompare(b);
+ });
+}
+
+function normalizeHTTPStatusFamily(code: string | undefined): string {
+ if (!code || !/^[2-5]\d\d$/.test(code)) return "unknown";
+ return `${code.charAt(0)}xx`;
+}
+
function escapePromQL(value: string) {
return value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n");
}
+
+function escapePromRegex(value: string) {
+ return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
+}
diff --git a/web/package.json b/web/package.json
index d62fff4b..8bae61e0 100644
--- a/web/package.json
+++ b/web/package.json
@@ -17,18 +17,19 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.968.0",
"@base-ui/react": "^1.0.0",
- "@better-auth/api-key": "^1.6.19",
+ "@better-auth/api-key": "1.6.23",
"@bprogress/next": "^3.2.12",
"@react-email/components": "^1.0.4",
"@react-email/render": "^2.0.2",
"acme-client": "^5.4.0",
- "better-auth": "^1.4.9",
+ "better-auth": "1.6.23",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cron-parser": "^5.4.0",
"cronstrue": "^3.9.0",
"drizzle-orm": "^0.45.1",
"inngest": "^4.3.0",
+ "input-otp": "^1.4.2",
"ip-address": "^10.1.0",
"jose": "^6.1.3",
"lucide-react": "^0.562.0",
@@ -37,6 +38,7 @@
"nodemailer": "^7.0.12",
"nuqs": "^2.8.6",
"pg": "^8.16.3",
+ "qrcode": "^1.5.4",
"react": "19.2.7",
"react-dom": "19.2.7",
"recharts": "^3.8.1",
@@ -56,6 +58,7 @@
"@types/node": "^24",
"@types/nodemailer": "^7.0.5",
"@types/pg": "^8.16.0",
+ "@types/qrcode": "^1.5.6",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@types/validator": "^13.15.10",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 00a9689c..83d1aea3 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -19,8 +19,8 @@ importers:
specifier: ^1.0.0
version: 1.5.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@better-auth/api-key':
- specifier: ^1.6.19
- version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))))(better-call@1.3.6(zod@4.4.3))
+ specifier: 1.6.23
+ version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))))(better-call@1.3.7(zod@4.4.3))
'@bprogress/next':
specifier: ^3.2.12
version: 3.2.12(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -34,8 +34,8 @@ importers:
specifier: ^5.4.0
version: 5.4.0
better-auth:
- specifier: ^1.4.9
- version: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))
+ specifier: 1.6.23
+ version: 1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -54,6 +54,9 @@ importers:
inngest:
specifier: ^4.3.0
version: 4.6.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(express@5.2.1)(hono@4.12.25)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@5.9.3)(zod@4.4.3)
+ input-otp:
+ specifier: ^1.4.2
+ version: 1.4.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
ip-address:
specifier: ^10.1.0
version: 10.2.0
@@ -78,6 +81,9 @@ importers:
pg:
specifier: ^8.16.3
version: 8.21.0
+ qrcode:
+ specifier: ^1.5.4
+ version: 1.5.4
react:
specifier: 19.2.7
version: 19.2.7
@@ -130,6 +136,9 @@ importers:
'@types/pg':
specifier: ^8.16.0
version: 8.20.0
+ '@types/qrcode':
+ specifier: ^1.5.6
+ version: 1.5.6
'@types/react':
specifier: 19.2.17
version: 19.2.17
@@ -434,22 +443,22 @@ packages:
'@types/react':
optional: true
- '@better-auth/api-key@1.6.19':
- resolution: {integrity: sha512-mSZhMu6tVZ6bxnlDnYL2bdxkEpfI3CeDW7l2tPSdZC12hupTowqgWsZvrWBcgc4t4arYuS2PoXxg7lVTetrgig==}
+ '@better-auth/api-key@1.6.23':
+ resolution: {integrity: sha512-HQMTv1GkY5FzE8BlUXdNhuKPBPUvusBYlAtIQElQjfsmGiPiBie7BdzuhcbXhAqJerunU299yvEge2WlZusp+Q==}
peerDependencies:
- '@better-auth/core': ^1.6.19
+ '@better-auth/core': ^1.6.23
'@better-auth/utils': 0.4.2
- better-auth: ^1.6.19
- better-call: 1.3.6
+ better-auth: ^1.6.23
+ better-call: 1.3.7
- '@better-auth/core@1.6.19':
- resolution: {integrity: sha512-ddE3Y9MoQ8t32QSO5Y8mV7pmnDAv5LdJjX1SPWUH6JUUmuOc7YEy3B5JfXZIJbRaXFdnAitN7pHPVa5u/dYAZA==}
+ '@better-auth/core@1.6.23':
+ resolution: {integrity: sha512-beEhOs0uVeOxYOZKUfIEBd/nQV2Bd4/6wyLxZ0OFkn6CMTK2Vi+hXuZLnyPBeB6RdHpebEoJWiHqwHxBIxgPDQ==}
peerDependencies:
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.1
'@cloudflare/workers-types': '>=4'
'@opentelemetry/api': ^1.9.0
- better-call: 1.3.6
+ better-call: 1.3.7
jose: ^6.1.0
kysely: ^0.28.5 || ^0.29.0
nanostores: ^1.0.1
@@ -459,46 +468,46 @@ packages:
'@opentelemetry/api':
optional: true
- '@better-auth/drizzle-adapter@1.6.19':
- resolution: {integrity: sha512-57C9ePorPmIEez6dHuQMz3hCTkYim0lfVRIoRtX7PiVfiRFB2bjXseQwrCJfQmkgMFlkp1s/c9nKgAjc2EvAIg==}
+ '@better-auth/drizzle-adapter@1.6.23':
+ resolution: {integrity: sha512-2+/PTVfIP9E7iz6af8TB3lhnowHUj9ljC66kECmHaFEdUqPgzHoWux9epotKwO7XDg2ui4ttWQ8CMeNFLvQeKQ==}
peerDependencies:
- '@better-auth/core': ^1.6.19
+ '@better-auth/core': ^1.6.23
'@better-auth/utils': 0.4.2
drizzle-orm: ^0.45.2
peerDependenciesMeta:
drizzle-orm:
optional: true
- '@better-auth/kysely-adapter@1.6.19':
- resolution: {integrity: sha512-DlmvllEd0nv8JL+plX3JB3WTmqDFnGFOmjmIiUDHo8R3PTAvC0ZaJq3Jk+LQLN5PyVQSUzXZKtvTQYaqRHzBaw==}
+ '@better-auth/kysely-adapter@1.6.23':
+ resolution: {integrity: sha512-zbNJsMbG09exfkGyvFqBLLqWoMPAUWjxCuUnEK5AsjbYoZeIjj/QGZgdf4CapVWryKxjA9Q6Jlr6fbiPpC3VAg==}
peerDependencies:
- '@better-auth/core': ^1.6.19
+ '@better-auth/core': ^1.6.23
'@better-auth/utils': 0.4.2
kysely: ^0.28.17 || ^0.29.0
peerDependenciesMeta:
kysely:
optional: true
- '@better-auth/memory-adapter@1.6.19':
- resolution: {integrity: sha512-cZ8iLRG/T8Oi/CqE9FTHj3z8pIOqRsINi50trWxPNwyY/Eyb7YCljrBi0PuqgIdyVs7BWfrrtEYTpO4ddfuwEw==}
+ '@better-auth/memory-adapter@1.6.23':
+ resolution: {integrity: sha512-krIiR0pIVkaKlAzm690n5bcMW4NGbqeMg0HQSD9fz/KcQF/eWLqcq9gG/BhHTj2i/y96qH+W5JWPmaSOS5iTgQ==}
peerDependencies:
- '@better-auth/core': ^1.6.19
+ '@better-auth/core': ^1.6.23
'@better-auth/utils': 0.4.2
- '@better-auth/mongo-adapter@1.6.19':
- resolution: {integrity: sha512-8AReXqhMGiGQIPEpGbmAhh+R4g70TsAVvzwdd6Aj4q+LTSwd3tqC89TFJ4eX8KSplxm9PFBZ6g6gsRmDd7urQg==}
+ '@better-auth/mongo-adapter@1.6.23':
+ resolution: {integrity: sha512-7+QdevitGlKBbP6JbiSk5SBnzPsKV/mDrQBGBn8hwByQLeJwqpqbuBPw7ZI8vzUlFfAAnyFiqwP3Eb8mxnp7pA==}
peerDependencies:
- '@better-auth/core': ^1.6.19
+ '@better-auth/core': ^1.6.23
'@better-auth/utils': 0.4.2
mongodb: ^6.0.0 || ^7.0.0
peerDependenciesMeta:
mongodb:
optional: true
- '@better-auth/prisma-adapter@1.6.19':
- resolution: {integrity: sha512-pXZBhR7/bzJb48IUHlGMyz9SM9h1OCO5GIIuHEllJYt8MKgrjtsnXfUkwZh6pAUEIp3WxBEYMMK96bfkqHiWEg==}
+ '@better-auth/prisma-adapter@1.6.23':
+ resolution: {integrity: sha512-2qSdzidq4tkb1eS5TTqb4Nzg0mdZWm3Qky9SYeXeb8PpVQbC2sxqJhEM5mK7y12uU6I8hc64wO9f7AFVNL+6UQ==}
peerDependencies:
- '@better-auth/core': ^1.6.19
+ '@better-auth/core': ^1.6.23
'@better-auth/utils': 0.4.2
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
@@ -508,10 +517,10 @@ packages:
prisma:
optional: true
- '@better-auth/telemetry@1.6.19':
- resolution: {integrity: sha512-bBaB6SMIsrD3WutDdm5YQ1bQyinANTimHD8RtpLWhNh/jIXvzgwVVCrDFqA256vcGC/ZRCydmtW8ZrUqNMW9Og==}
+ '@better-auth/telemetry@1.6.23':
+ resolution: {integrity: sha512-/R2Kb+z2BpDOOWwVHqOk+c0VNpuwfCv4Hp5Yr9003WIZPax/zyNraGLB9CFE8qF2gZW8Dsz419k4I8CPrGzpDA==}
peerDependencies:
- '@better-auth/core': ^1.6.19
+ '@better-auth/core': ^1.6.23
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.1
@@ -2602,6 +2611,9 @@ packages:
'@types/pg@8.20.0':
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
+ '@types/qrcode@1.5.6':
+ resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
+
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -3008,8 +3020,8 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
- better-auth@1.6.19:
- resolution: {integrity: sha512-68eXWKj0sxa0xW4+n4tENd6Co94UCynPKe1fncmO6kIB3XhSXWgwDEpiUouJV2dmLBrHM1FPkoI6Q5597zCGpQ==}
+ better-auth@1.6.23:
+ resolution: {integrity: sha512-4vOaRd9UiKGKm9R+ej0jjU1es3MiJIiNc9Qq3VCnYqOZ4/nb5272QqTxWYoDxyUXl5x6A2x2we5KZKQO9teTQQ==}
peerDependencies:
'@lynx-js/react': '*'
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
@@ -3070,8 +3082,8 @@ packages:
vue:
optional: true
- better-call@1.3.6:
- resolution: {integrity: sha512-no1jI+h6Bkxs1NVBo4rONbVIzsPjZ8IUu7IHaJBiFwVX1XEQGN8KpHots5fSWmXe9nNyLuLIcgx6WEUcE6EDaA==}
+ better-call@1.3.7:
+ resolution: {integrity: sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==}
peerDependencies:
zod: ^4.0.0
peerDependenciesMeta:
@@ -3134,6 +3146,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
+ camelcase@5.3.1:
+ resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
+ engines: {node: '>=6'}
+
caniuse-lite@1.0.30001799:
resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
@@ -3176,6 +3192,9 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
+ cliui@6.0.0:
+ resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
+
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -3360,6 +3379,10 @@ packages:
supports-color:
optional: true
+ decamelize@1.2.0:
+ resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+ engines: {node: '>=0.10.0'}
+
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
@@ -3425,6 +3448,9 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
+ dijkstrajs@1.0.3:
+ resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
+
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -3903,6 +3929,10 @@ packages:
resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==}
engines: {node: '>=6'}
+ find-up@4.1.0:
+ resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
+ engines: {node: '>=8'}
+
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -4212,6 +4242,12 @@ packages:
typescript:
optional: true
+ input-otp@1.4.2:
+ resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
+ peerDependencies:
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
+
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@@ -4600,6 +4636,10 @@ packages:
resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==}
engines: {node: '>=6'}
+ locate-path@5.0.0:
+ resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
+ engines: {node: '>=8'}
+
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -4937,6 +4977,10 @@ packages:
resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==}
engines: {node: '>=6'}
+ p-locate@4.1.0:
+ resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
+ engines: {node: '>=8'}
+
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
@@ -5065,6 +5109,10 @@ packages:
resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==}
engines: {node: '>=8'}
+ pngjs@5.0.0:
+ resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
+ engines: {node: '>=10.13.0'}
+
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -5148,6 +5196,11 @@ packages:
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
engines: {node: '>=16.0.0'}
+ qrcode@1.5.4:
+ resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
+ engines: {node: '>=10.13.0'}
+ hasBin: true
+
qs@6.15.2:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'}
@@ -5234,6 +5287,9 @@ packages:
resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
+ require-main-filename@2.0.0:
+ resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
@@ -5333,6 +5389,9 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
+ set-blocking@2.0.0:
+ resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+
set-cookie-parser@3.1.0:
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
@@ -5847,6 +5906,9 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'}
+ which-module@2.0.1:
+ resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
+
which-typed-array@1.1.22:
resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
engines: {node: '>= 0.4'}
@@ -5870,6 +5932,10 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
+ wrap-ansi@6.2.0:
+ resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
+ engines: {node: '>=8'}
+
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -5893,6 +5959,9 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
+ y18n@4.0.3:
+ resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
+
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -5905,10 +5974,18 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
+ yargs-parser@18.1.3:
+ resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
+ engines: {node: '>=6'}
+
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
+ yargs@15.4.1:
+ resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
+ engines: {node: '>=8'}
+
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
@@ -6394,21 +6471,21 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
- '@better-auth/api-key@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))))(better-call@1.3.6(zod@4.4.3))':
+ '@better-auth/api-key@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))))(better-call@1.3.7(zod@4.4.3))':
dependencies:
- '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
+ '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
- better-auth: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))
- better-call: 1.3.6(zod@4.4.3)
+ better-auth: 1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))
+ better-call: 1.3.7(zod@4.4.3)
zod: 4.4.3
- '@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)':
+ '@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)':
dependencies:
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.1
'@opentelemetry/semantic-conventions': 1.41.1
'@standard-schema/spec': 1.1.0
- better-call: 1.3.6(zod@4.4.3)
+ better-call: 1.3.7(zod@4.4.3)
jose: 6.2.3
kysely: 0.29.2
nanostores: 1.3.0
@@ -6416,38 +6493,38 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.1
- '@better-auth/drizzle-adapter@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))':
+ '@better-auth/drizzle-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))':
dependencies:
- '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
+ '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
optionalDependencies:
drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0)
- '@better-auth/kysely-adapter@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)':
+ '@better-auth/kysely-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)':
dependencies:
- '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
+ '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
optionalDependencies:
kysely: 0.29.2
- '@better-auth/memory-adapter@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
+ '@better-auth/memory-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
dependencies:
- '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
+ '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
- '@better-auth/mongo-adapter@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
+ '@better-auth/mongo-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
dependencies:
- '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
+ '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
- '@better-auth/prisma-adapter@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
+ '@better-auth/prisma-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
dependencies:
- '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
+ '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
- '@better-auth/telemetry@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)':
+ '@better-auth/telemetry@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)':
dependencies:
- '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
+ '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.1
@@ -8425,6 +8502,10 @@ snapshots:
pg-protocol: 1.14.0
pg-types: 2.2.0
+ '@types/qrcode@1.5.6':
+ dependencies:
+ '@types/node': 24.13.2
+
'@types/react-dom@19.2.3(@types/react@19.2.17)':
dependencies:
'@types/react': 19.2.17
@@ -8838,20 +8919,20 @@ snapshots:
baseline-browser-mapping@2.10.37: {}
- better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))):
+ better-auth@1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))):
dependencies:
- '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
- '@better-auth/drizzle-adapter': 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))
- '@better-auth/kysely-adapter': 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)
- '@better-auth/memory-adapter': 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
- '@better-auth/mongo-adapter': 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
- '@better-auth/prisma-adapter': 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
- '@better-auth/telemetry': 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)
+ '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
+ '@better-auth/drizzle-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))
+ '@better-auth/kysely-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)
+ '@better-auth/memory-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
+ '@better-auth/mongo-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
+ '@better-auth/prisma-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
+ '@better-auth/telemetry': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.1
'@noble/ciphers': 2.2.0
'@noble/hashes': 2.2.0
- better-call: 1.3.6(zod@4.4.3)
+ better-call: 1.3.7(zod@4.4.3)
defu: 6.1.7
jose: 6.2.3
kysely: 0.29.2
@@ -8869,7 +8950,7 @@ snapshots:
- '@cloudflare/workers-types'
- '@opentelemetry/api'
- better-call@1.3.6(zod@4.4.3):
+ better-call@1.3.7(zod@4.4.3):
dependencies:
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.1
@@ -8948,6 +9029,8 @@ snapshots:
callsites@3.1.0: {}
+ camelcase@5.3.1: {}
+
caniuse-lite@1.0.30001799: {}
canonicalize@1.0.8: {}
@@ -8979,6 +9062,12 @@ snapshots:
client-only@0.0.1: {}
+ cliui@6.0.0:
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 6.2.0
+
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -9140,6 +9229,8 @@ snapshots:
dependencies:
ms: 2.1.3
+ decamelize@1.2.0: {}
+
decimal.js-light@2.5.1: {}
dedent@1.7.2: {}
@@ -9183,6 +9274,8 @@ snapshots:
diff@8.0.4: {}
+ dijkstrajs@1.0.3: {}
+
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -9849,6 +9942,11 @@ snapshots:
dependencies:
locate-path: 3.0.0
+ find-up@4.1.0:
+ dependencies:
+ locate-path: 5.0.0
+ path-exists: 4.0.0
+
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -10170,6 +10268,11 @@ snapshots:
- encoding
- supports-color
+ input-otp@1.4.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -10493,6 +10596,10 @@ snapshots:
p-locate: 3.0.0
path-exists: 3.0.0
+ locate-path@5.0.0:
+ dependencies:
+ p-locate: 4.1.0
+
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -10811,6 +10918,10 @@ snapshots:
dependencies:
p-limit: 2.3.0
+ p-locate@4.1.0:
+ dependencies:
+ p-limit: 2.3.0
+
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
@@ -10915,6 +11026,8 @@ snapshots:
dependencies:
find-up: 3.0.0
+ pngjs@5.0.0: {}
+
possible-typed-array-names@1.1.0: {}
postcss-selector-parser@7.1.4:
@@ -10996,6 +11109,12 @@ snapshots:
pvutils@1.1.5: {}
+ qrcode@1.5.4:
+ dependencies:
+ dijkstrajs: 1.0.3
+ pngjs: 5.0.0
+ yargs: 15.4.1
+
qs@6.15.2:
dependencies:
side-channel: 1.1.1
@@ -11104,6 +11223,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ require-main-filename@2.0.0: {}
+
reselect@5.1.1: {}
reselect@5.2.0: {}
@@ -11238,6 +11359,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ set-blocking@2.0.0: {}
+
set-cookie-parser@3.1.0: {}
set-function-length@1.2.2:
@@ -11846,6 +11969,8 @@ snapshots:
is-weakmap: 2.0.2
is-weakset: 2.0.4
+ which-module@2.0.1: {}
+
which-typed-array@1.1.22:
dependencies:
available-typed-arrays: 1.0.7
@@ -11871,6 +11996,12 @@ snapshots:
word-wrap@1.2.5: {}
+ wrap-ansi@6.2.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -11894,14 +12025,35 @@ snapshots:
xtend@4.0.2: {}
+ y18n@4.0.3: {}
+
y18n@5.0.8: {}
yallist@3.1.1: {}
yaml@2.9.0: {}
+ yargs-parser@18.1.3:
+ dependencies:
+ camelcase: 5.3.1
+ decamelize: 1.2.0
+
yargs-parser@21.1.1: {}
+ yargs@15.4.1:
+ dependencies:
+ cliui: 6.0.0
+ decamelize: 1.2.0
+ find-up: 4.1.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ require-main-filename: 2.0.0
+ set-blocking: 2.0.0
+ string-width: 4.2.3
+ which-module: 2.0.1
+ y18n: 4.0.3
+ yargs-parser: 18.1.3
+
yargs@17.7.2:
dependencies:
cliui: 8.0.1
diff --git a/web/public/setup.sh b/web/public/setup.sh
index 30725e66..1509fe3a 100644
--- a/web/public/setup.sh
+++ b/web/public/setup.sh
@@ -407,6 +407,29 @@ accessLog:
names:
User-Agent: keep
+metrics:
+ prometheus:
+ entryPoint: metrics
+ addEntryPointsLabels: false
+ addRoutersLabels: false
+ addServicesLabels: true
+ buckets:
+ - 0.005
+ - 0.01
+ - 0.025
+ - 0.05
+ - 0.075
+ - 0.1
+ - 0.25
+ - 0.5
+ - 0.75
+ - 1
+ - 2.5
+ - 5
+ - 10
+ - 30
+ - 60
+
api:
dashboard: false
insecure: false
@@ -418,6 +441,8 @@ experimental:
version: v1.5.0
entryPoints:
+ metrics:
+ address: "127.0.0.1:9100"
web:
address: ":80"
websecure:
diff --git a/web/tests/two-factor.test.ts b/web/tests/two-factor.test.ts
new file mode 100644
index 00000000..2a25755f
--- /dev/null
+++ b/web/tests/two-factor.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+import {
+ DEFAULT_AUTH_REDIRECT,
+ getSafeAuthRedirect,
+ getTotpSecret,
+} from "@/lib/two-factor";
+
+describe("two-factor helpers", () => {
+ it("keeps internal auth redirects", () => {
+ expect(getSafeAuthRedirect("/dashboard/projects/demo")).toBe(
+ "/dashboard/projects/demo",
+ );
+ });
+
+ it("falls back for missing or external auth redirects", () => {
+ expect(getSafeAuthRedirect(null)).toBe(DEFAULT_AUTH_REDIRECT);
+ expect(getSafeAuthRedirect("https://example.com")).toBe(
+ DEFAULT_AUTH_REDIRECT,
+ );
+ expect(getSafeAuthRedirect("//example.com")).toBe(DEFAULT_AUTH_REDIRECT);
+ expect(getSafeAuthRedirect("/\\example.com")).toBe(DEFAULT_AUTH_REDIRECT);
+ expect(getSafeAuthRedirect("/\\/example.com")).toBe(DEFAULT_AUTH_REDIRECT);
+ expect(getSafeAuthRedirect(" /dashboard")).toBe(DEFAULT_AUTH_REDIRECT);
+ expect(getSafeAuthRedirect("/dashboard\n")).toBe(DEFAULT_AUTH_REDIRECT);
+ });
+
+ it("extracts the secret from a TOTP URI", () => {
+ expect(
+ getTotpSecret(
+ "otpauth://totp/Techulus%20Cloud:agent@example.com?secret=ABC123&issuer=Techulus%20Cloud",
+ ),
+ ).toBe("ABC123");
+ });
+
+ it("returns an empty secret for malformed TOTP URIs", () => {
+ expect(getTotpSecret("not a uri")).toBe("");
+ });
+});
diff --git a/web/tests/victoria-logs-request-stats.test.ts b/web/tests/victoria-logs-request-stats.test.ts
deleted file mode 100644
index ca732e25..00000000
--- a/web/tests/victoria-logs-request-stats.test.ts
+++ /dev/null
@@ -1,161 +0,0 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
-import {
- buildRequestStatsBuckets,
- createEmptyHttpRequestStats,
- getRequestStatsWindow,
- parseRequestStatsRange,
- parseRequestStatsRows,
-} from "@/lib/victoria-logs";
-
-const SERVICE_ID = "123e4567-e89b-42d3-a456-426614174000";
-
-describe("VictoriaLogs request stats", () => {
- afterEach(() => {
- vi.restoreAllMocks();
- vi.unstubAllEnvs();
- vi.unstubAllGlobals();
- vi.resetModules();
- });
-
- it("parses JSON lines and ignores malformed rows", () => {
- const rows = parseRequestStatsRows(
- [
- '{"_time":"2026-07-02T00:00:00Z","status":200,"requests":"10"}',
- "not-json",
- '{"_time":"2026-07-02T00:01:00Z","status":"500","requests":5}',
- ].join("\n"),
- );
-
- expect(rows).toEqual([
- {
- _time: "2026-07-02T00:00:00Z",
- status: 200,
- requests: "10",
- },
- {
- _time: "2026-07-02T00:01:00Z",
- status: "500",
- requests: 5,
- },
- ]);
- });
-
- it("builds complete buckets and fills gaps with zeroes", () => {
- const buckets = buildRequestStatsBuckets(
- [
- { _time: "2026-07-02T00:00:10Z", status: "200", requests: "3" },
- { _time: "2026-07-02T00:02:05Z", status: "404", requests: 7 },
- { _time: "2026-07-02T00:02:15Z", status: "500", requests: "2" },
- ],
- {
- start: new Date("2026-07-02T00:00:00Z"),
- end: new Date("2026-07-02T00:02:30Z"),
- stepSeconds: 60,
- },
- );
-
- expect(buckets).toEqual([
- {
- timestamp: "2026-07-02T00:00:00.000Z",
- totalRequests: 3,
- statuses: { "200": 3 },
- },
- {
- timestamp: "2026-07-02T00:01:00.000Z",
- totalRequests: 0,
- statuses: {},
- },
- {
- timestamp: "2026-07-02T00:02:00.000Z",
- totalRequests: 9,
- statuses: { "404": 7, "500": 2 },
- },
- ]);
- });
-
- it("falls back to the default range for unsupported values", () => {
- expect(parseRequestStatsRange("7d")).toBe("7d");
- expect(parseRequestStatsRange("week")).toBe("week");
- expect(parseRequestStatsRange("90d")).toBe("7d");
- expect(parseRequestStatsRange(null)).toBe("7d");
- });
-
- it("uses a Monday UTC start for the week-to-date range", () => {
- const window = getRequestStatsWindow(
- "week",
- new Date("2026-07-02T12:34:00Z"),
- );
-
- expect(window.start.toISOString()).toBe("2026-06-29T00:00:00.000Z");
- expect(window.end.toISOString()).toBe("2026-07-02T12:34:00.000Z");
- expect(window.stepSeconds).toBe(1800);
- });
-
- it("handles Sunday and Monday UTC week boundaries", () => {
- expect(
- getRequestStatsWindow(
- "week",
- new Date("2026-07-05T23:59:59Z"),
- ).start.toISOString(),
- ).toBe("2026-06-29T00:00:00.000Z");
- expect(
- getRequestStatsWindow(
- "week",
- new Date("2026-07-06T00:00:00Z"),
- ).start.toISOString(),
- ).toBe("2026-07-06T00:00:00.000Z");
- });
-
- it("queries a week-to-date stats window with one LogSQL range filter", async () => {
- vi.stubEnv("VICTORIA_LOGS_URL", "http://victoria.test");
- vi.stubEnv("VICTORIA_LOGS_PRIVATE_URL", "");
- vi.resetModules();
-
- const fetchMock = vi.fn(async (input: string | URL | Request) => {
- const url = new URL(String(input));
- const query = url.searchParams.get("query");
-
- expect(url.pathname).toBe("/select/logsql/query");
- expect(url.searchParams.get("limit")).toBe("11200");
- expect(query).toBe(
- `service_id:${SERVICE_ID} log_type:http _time:[2026-06-29T00:00:00.000Z, 2026-07-02T12:34:00.000Z) | stats by (_time:30m, status) count() as requests | sort by (_time)`,
- );
-
- return new Response(
- '{"_time":"2026-07-02T12:30:00Z","status":"200","requests":"3"}\n',
- );
- });
- vi.stubGlobal("fetch", fetchMock);
-
- const { queryHttpRequestStats } = await import("@/lib/victoria-logs");
- const stats = await queryHttpRequestStats({
- serviceId: SERVICE_ID,
- range: "week",
- now: new Date("2026-07-02T12:34:00Z"),
- });
-
- expect(fetchMock).toHaveBeenCalledTimes(1);
- expect(stats).toMatchObject({
- range: "week",
- windowStart: "2026-06-29T00:00:00.000Z",
- windowEnd: "2026-07-02T12:34:00.000Z",
- stepSeconds: 1800,
- totalRequests: 3,
- statusCodes: ["200"],
- });
- });
-
- it("creates an empty non-breaking stats payload", () => {
- expect(
- createEmptyHttpRequestStats("1h", new Date("2026-07-02T01:00:00Z")),
- ).toMatchObject({
- range: "1h",
- windowStart: "2026-07-02T00:00:00.000Z",
- windowEnd: "2026-07-02T01:00:00.000Z",
- stepSeconds: 60,
- totalRequests: 0,
- statusCodes: [],
- buckets: [],
- });
- });
-});
diff --git a/web/tests/victoria-metrics-service-metrics.test.ts b/web/tests/victoria-metrics-service-metrics.test.ts
new file mode 100644
index 00000000..40010cc2
--- /dev/null
+++ b/web/tests/victoria-metrics-service-metrics.test.ts
@@ -0,0 +1,149 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ buildTraefikServiceMatcher,
+ createEmptyServiceMetrics,
+ formatPromDuration,
+ queryServiceMetrics,
+} from "@/lib/victoria-metrics";
+
+const SERVICE_ID = "123e4567-e89b-42d3-a456-426614174000";
+const END_TS = Date.parse("2026-07-02T12:30:00Z") / 1000;
+
+describe("VictoriaMetrics service metrics", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllEnvs();
+ vi.unstubAllGlobals();
+ vi.resetModules();
+ });
+
+ it("formats Prometheus durations", () => {
+ expect(formatPromDuration(60)).toBe("1m");
+ expect(formatPromDuration(300)).toBe("5m");
+ expect(formatPromDuration(7200)).toBe("2h");
+ expect(formatPromDuration(45)).toBe("45s");
+ });
+
+ it("matches Traefik service labels with optional provider suffix", () => {
+ expect(buildTraefikServiceMatcher(SERVICE_ID)).toBe(
+ `^${SERVICE_ID}(@file)?$`,
+ );
+ expect(buildTraefikServiceMatcher("svc.1")).toBe("^svc\\.1(@file)?$");
+ });
+
+ it("creates an empty metrics payload", () => {
+ expect(
+ createEmptyServiceMetrics("1h", new Date("2026-07-02T01:00:00Z")),
+ ).toMatchObject({
+ metricsEnabled: false,
+ range: "1h",
+ windowStart: "2026-07-02T00:00:00.000Z",
+ windowEnd: "2026-07-02T01:00:00.000Z",
+ stepSeconds: 60,
+ totalRequests: 0,
+ statusCodes: [],
+ buckets: [],
+ });
+ });
+
+ it("queries service metrics from VictoriaMetrics only", async () => {
+ vi.stubEnv("VICTORIA_METRICS_URL", "http://victoria.test");
+ vi.stubEnv("VICTORIA_METRICS_PRIVATE_URL", "");
+
+ const queries: string[] = [];
+ const starts: string[] = [];
+ const fetchMock = vi.fn(async (input: string | URL | Request) => {
+ const url = new URL(String(input));
+ const query = url.searchParams.get("query") || "";
+ queries.push(query);
+ starts.push(url.searchParams.get("start") || "");
+
+ expect(url.pathname).toBe("/api/v1/query_range");
+
+ if (query.includes("traefik_service_requests_total")) {
+ return jsonResponse([
+ {
+ metric: { code: "200" },
+ values: [[END_TS, "3"]],
+ },
+ {
+ metric: { code: "204" },
+ values: [[END_TS, "2"]],
+ },
+ {
+ metric: { code: "301" },
+ values: [[END_TS, "1"]],
+ },
+ {
+ metric: { code: "404" },
+ values: [[END_TS, "4"]],
+ },
+ {
+ metric: { code: "500" },
+ values: [[END_TS, "5"]],
+ },
+ {
+ metric: { code: "502" },
+ values: [[END_TS, "6"]],
+ },
+ ]);
+ }
+ if (query.includes("histogram_quantile(0.95")) {
+ return jsonResponse([{ metric: {}, values: [[END_TS, "0.123"]] }]);
+ }
+ if (query.includes("traefik_service_responses_bytes_total")) {
+ return jsonResponse([{ metric: {}, values: [[END_TS, "2048"]] }]);
+ }
+ if (query.includes("techulus_service_cpu_usage_percent")) {
+ return jsonResponse([{ metric: {}, values: [[END_TS, "42"]] }]);
+ }
+
+ return jsonResponse([]);
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ const stats = await queryServiceMetrics({
+ serviceId: SERVICE_ID,
+ range: "24h",
+ now: new Date("2026-07-02T12:34:00Z"),
+ });
+
+ expect(fetchMock).toHaveBeenCalledTimes(10);
+ expect(queries.some((query) => query.includes("LogSQL"))).toBe(false);
+ expect(queries).toContain(
+ `sum by (code) (increase(traefik_service_requests_total{service=~"^${SERVICE_ID}(@file)?$"}[5m]))`,
+ );
+ expect(queries).toContain(
+ `sum(avg_over_time(techulus_service_cpu_usage_percent{service_id="${SERVICE_ID}"}[5m]))`,
+ );
+ expect(queries).toContain(
+ `sum(avg_over_time(techulus_service_memory_usage_percent{service_id="${SERVICE_ID}"}[5m]))`,
+ );
+ expect(
+ starts.every((start) => start === String(END_TS - 24 * 60 * 60 + 5 * 60)),
+ ).toBe(true);
+ expect(stats.totalRequests).toBe(21);
+ expect(stats.statusCodes).toEqual(["2xx", "3xx", "4xx", "5xx"]);
+ expect(stats.buckets).toHaveLength(288);
+ expect(stats.windowEnd).toBe("2026-07-02T12:30:00.000Z");
+ expect(stats.buckets[0]?.timestamp).toBe("2026-07-01T12:35:00.000Z");
+
+ const lastBucket = stats.buckets.at(-1);
+ expect(lastBucket).toMatchObject({
+ totalRequests: 21,
+ statuses: { "2xx": 5, "3xx": 1, "4xx": 4, "5xx": 11 },
+ p95ResponseTimeMs: 123,
+ egressBytesPerSecond: 2048,
+ cpuUsagePercent: 42,
+ });
+ });
+});
+
+function jsonResponse(result: unknown[]) {
+ return new Response(
+ JSON.stringify({
+ status: "success",
+ data: { result },
+ }),
+ );
+}