feat: implement system metrics and fix monitoring charts
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled

Added /api/system/metrics with CPU/Mem/Disk/Load data using gopsutil. Updated Hub to track active WebSocket listeners. Verified log format for monitoring charts.
This commit is contained in:
2026-03-19 13:15:48 -04:00
parent 3f1e6d3407
commit dec4b927dc
5 changed files with 97 additions and 10 deletions

View File

@@ -4,6 +4,7 @@ import (
"database/sql"
"fmt"
"net/http"
"os"
"strings"
"time"
@@ -12,6 +13,12 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/mem"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/load"
"github.com/shirou/gopsutil/v3/process"
)
type ApiResponse struct {
@@ -955,7 +962,6 @@ func (s *Server) handleGetModels(c *gin.Context) {
usedOnly := c.Query("used_only") == "true"
// Registry provider normalized name -> Proxy-internal provider ID
// This ensures we only show models from YOUR providers.
allowedRegistryProviders := map[string]string{
"openai": "openai",
"google": "gemini",
@@ -983,7 +989,6 @@ func (s *Server) handleGetModels(c *gin.Context) {
err := s.database.Select(&pairs, "SELECT DISTINCT model, provider FROM llm_requests WHERE status = 'success'")
if err == nil {
for _, p := range pairs {
// Key format: "gpt-4o:openai"
usedPairs[fmt.Sprintf("%s:%s", p.Model, p.Provider)] = true
}
}
@@ -992,14 +997,12 @@ func (s *Server) handleGetModels(c *gin.Context) {
var result []gin.H
if s.registry != nil {
for pID, pInfo := range s.registry.Providers {
// Only include models from the 4 allowed registry IDs
proxyProvider, allowed := allowedRegistryProviders[pID]
if !allowed {
continue
}
for mID, mMeta := range pInfo.Models {
// If usedOnly is true, only include if this exact (model, provider) was logged
if usedOnly && !usedPairs[fmt.Sprintf("%s:%s", mID, proxyProvider)] {
continue
}
@@ -1043,7 +1046,7 @@ func (s *Server) handleGetModels(c *gin.Context) {
result = append(result, gin.H{
"id": mID,
"name": mMeta.Name,
"provider": proxyProvider, // Correctly normalized provider name
"provider": proxyProvider,
"enabled": enabled,
"prompt_cost": promptCost,
"completion_cost": completionCost,
@@ -1218,6 +1221,42 @@ func (s *Server) handleSystemHealth(c *gin.Context) {
}))
}
func (s *Server) handleSystemMetrics(c *gin.Context) {
v, _ := mem.VirtualMemory()
c_usage, _ := cpu.Percent(time.Second, false)
d, _ := disk.Usage("/")
l, _ := load.Avg()
p, _ := process.NewProcess(int32(os.Getpid()))
rss, _ := p.MemoryInfo()
cpuPercent := 0.0
if len(c_usage) > 0 {
cpuPercent = c_usage[0]
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{
"cpu": gin.H{
"usage_percent": fmt.Sprintf("%.1f", cpuPercent),
"load_average": []float64{l.Load1, l.Load5, l.Load15},
},
"memory": gin.H{
"used_mb": v.Used / 1024 / 1024,
"total_mb": v.Total / 1024 / 1024,
"usage_percent": fmt.Sprintf("%.1f", v.UsedPercent),
"process_rss_mb": rss.RSS / 1024 / 1024,
},
"disk": gin.H{
"used_gb": float64(d.Used) / 1024 / 1024 / 1024,
"total_gb": float64(d.Total) / 1024 / 1024 / 1024,
"usage_percent": fmt.Sprintf("%.1f", d.UsedPercent),
},
"connections": gin.H{
"db_active": s.database.Stats().OpenConnections,
"websocket_listeners": s.hub.GetClientCount(),
},
}))
}
func (s *Server) handleGetSettings(c *gin.Context) {
providerCount := 0
modelCount := 0
@@ -1268,6 +1307,7 @@ func (s *Server) handleGetLogs(c *gin.Context) {
Model string `json:"model"`
Tokens int `json:"tokens"`
Status string `json:"status"`
Duration int `json:"duration"`
}
uiLogs := make([]UILog, len(logs))
@@ -1288,6 +1328,10 @@ func (s *Server) handleGetLogs(c *gin.Context) {
if l.TotalTokens != nil {
tokens = *l.TotalTokens
}
duration := 0
if l.DurationMS != nil {
duration = *l.DurationMS
}
uiLogs[i] = UILog{
Timestamp: l.Timestamp.Format(time.RFC3339),
@@ -1296,6 +1340,7 @@ func (s *Server) handleGetLogs(c *gin.Context) {
Model: model,
Tokens: tokens,
Status: l.Status,
Duration: duration,
}
}

View File

@@ -74,6 +74,8 @@ func NewServer(cfg *config.Config, database *db.DB) *Server {
}
func (s *Server) setupRoutes() {
s.router.Use(middleware.AuthMiddleware(s.database))
// Static files
s.router.StaticFile("/", "./static/index.html")
s.router.StaticFile("/favicon.ico", "./static/favicon.ico")
@@ -134,6 +136,7 @@ func (s *Server) setupRoutes() {
admin.DELETE("/users/:id", s.handleDeleteUser)
admin.GET("/system/health", s.handleSystemHealth)
admin.GET("/system/metrics", s.handleSystemMetrics)
admin.GET("/system/settings", s.handleGetSettings)
admin.POST("/system/backup", s.handleCreateBackup)
admin.GET("/system/logs", s.handleGetLogs)

View File

@@ -4,6 +4,7 @@ import (
"log"
"net/http"
"sync"
"sync/atomic"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
@@ -18,11 +19,12 @@ var upgrader = websocket.Upgrader{
}
type Hub struct {
clients map[*websocket.Conn]bool
broadcast chan interface{}
register chan *websocket.Conn
unregister chan *websocket.Conn
mu sync.Mutex
clients map[*websocket.Conn]bool
broadcast chan interface{}
register chan *websocket.Conn
unregister chan *websocket.Conn
mu sync.Mutex
clientCount int32
}
func NewHub() *Hub {
@@ -40,6 +42,7 @@ func (h *Hub) Run() {
case client := <-h.register:
h.mu.Lock()
h.clients[client] = true
atomic.AddInt32(&h.clientCount, 1)
h.mu.Unlock()
log.Println("WebSocket client registered")
case client := <-h.unregister:
@@ -47,6 +50,7 @@ func (h *Hub) Run() {
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
client.Close()
atomic.AddInt32(&h.clientCount, -1)
}
h.mu.Unlock()
log.Println("WebSocket client unregistered")
@@ -58,6 +62,7 @@ func (h *Hub) Run() {
log.Printf("WebSocket error: %v", err)
client.Close()
delete(h.clients, client)
atomic.AddInt32(&h.clientCount, -1)
}
}
h.mu.Unlock()
@@ -65,6 +70,10 @@ func (h *Hub) Run() {
}
}
func (h *Hub) GetClientCount() int {
return int(atomic.LoadInt32(&h.clientCount))
}
func (s *Server) handleWebSocket(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {