af2c5b95f7
- Split 1474-line dashboard.go into 5 domain files (clients, providers, users, system) - Unit tests for ModelRegistry.FindModel and CalculateCost - go mod tidy + verify (deps clean) - .gitignore excludes tool cache dirs (.pi-lens/, .opencode/)
535 lines
14 KiB
Go
535 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gophergate/internal/db"
|
|
"gophergate/internal/models"
|
|
"gophergate/internal/utils"
|
|
"log/slog"
|
|
|
|
"github.com/shirou/gopsutil/v3/cpu"
|
|
"github.com/shirou/gopsutil/v3/disk"
|
|
"github.com/shirou/gopsutil/v3/load"
|
|
"github.com/shirou/gopsutil/v3/mem"
|
|
"github.com/shirou/gopsutil/v3/process"
|
|
)
|
|
|
|
type ApiResponse struct {
|
|
Success bool `json:"success"`
|
|
Data interface{} `json:"data,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func SuccessResponse(data interface{}) ApiResponse {
|
|
return ApiResponse{Success: true, Data: data}
|
|
}
|
|
|
|
func ErrorResponse(err string) ApiResponse {
|
|
return ApiResponse{Success: false, Error: err}
|
|
}
|
|
|
|
func (s *Server) adminAuthMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
|
if token == "" {
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorResponse("Not authenticated"))
|
|
return
|
|
}
|
|
|
|
session, _, err := s.sessions.ValidateSession(token)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorResponse("Session expired or invalid"))
|
|
return
|
|
}
|
|
|
|
if session.Role != "admin" {
|
|
c.AbortWithStatusJSON(http.StatusForbidden, ErrorResponse("Admin access required"))
|
|
return
|
|
}
|
|
|
|
c.Set("session", session)
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
type LoginRequest struct {
|
|
Username string `json:"username" binding:"required"`
|
|
Password string `json:"password" binding:"required"`
|
|
}
|
|
|
|
func (s *Server) handleLogin(c *gin.Context) {
|
|
var req LoginRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
|
|
return
|
|
}
|
|
|
|
var user db.User
|
|
err := s.database.Get(&user, "SELECT username, password_hash, display_name, role, must_change_password FROM users WHERE username = ?", req.Username)
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, ErrorResponse("Invalid username or password"))
|
|
return
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
|
c.JSON(http.StatusUnauthorized, ErrorResponse("Invalid username or password"))
|
|
return
|
|
}
|
|
|
|
displayName := user.Username
|
|
if user.DisplayName != nil {
|
|
displayName = *user.DisplayName
|
|
}
|
|
|
|
token, err := s.sessions.CreateSession(user.Username, displayName, user.Role)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to create session"))
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
|
"token": token,
|
|
"must_change_password": user.MustChangePassword,
|
|
"user": user,
|
|
}))
|
|
}
|
|
|
|
func (s *Server) handleAuthStatus(c *gin.Context) {
|
|
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
|
session, _, err := s.sessions.ValidateSession(token)
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, ErrorResponse("Not authenticated"))
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
|
"authenticated": true,
|
|
"user": gin.H{
|
|
"username": session.Username,
|
|
"role": session.Role,
|
|
"display_name": session.DisplayName,
|
|
},
|
|
}))
|
|
}
|
|
|
|
type ChangePasswordRequest struct {
|
|
CurrentPassword string `json:"current_password" binding:"required"`
|
|
NewPassword string `json:"new_password" binding:"required"`
|
|
}
|
|
|
|
func (s *Server) handleChangePassword(c *gin.Context) {
|
|
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
|
session, _, err := s.sessions.ValidateSession(token)
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, ErrorResponse("Not authenticated"))
|
|
return
|
|
}
|
|
|
|
var req ChangePasswordRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
|
|
return
|
|
}
|
|
|
|
var user db.User
|
|
err = s.database.Get(&user, "SELECT password_hash FROM users WHERE username = ?", session.Username)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, ErrorResponse("User not found"))
|
|
return
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil {
|
|
c.JSON(http.StatusUnauthorized, ErrorResponse("Current password incorrect"))
|
|
return
|
|
}
|
|
|
|
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), 12)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to hash new password"))
|
|
return
|
|
}
|
|
|
|
_, err = s.database.Exec("UPDATE users SET password_hash = ?, must_change_password = 0 WHERE username = ?", string(newHash), session.Username)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to update password"))
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Password updated successfully"}))
|
|
}
|
|
|
|
func (s *Server) handleLogout(c *gin.Context) {
|
|
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
|
if err := s.sessions.RevokeSession(token); err != nil {
|
|
fmt.Printf("Error revoking session: %v\n", err)
|
|
}
|
|
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Logged out"}))
|
|
}
|
|
|
|
type UsagePeriodFilter struct {
|
|
Period string `form:"period"`
|
|
From string `form:"from"`
|
|
To string `form:"to"`
|
|
}
|
|
|
|
func (f *UsagePeriodFilter) ToSQL() (string, []interface{}) {
|
|
period := f.Period
|
|
if period == "" {
|
|
period = "all"
|
|
}
|
|
|
|
if period == "custom" {
|
|
var clauses []string
|
|
var binds []interface{}
|
|
if f.From != "" {
|
|
clauses = append(clauses, "timestamp >= ?")
|
|
binds = append(binds, f.From)
|
|
}
|
|
if f.To != "" {
|
|
clauses = append(clauses, "timestamp <= ?")
|
|
binds = append(binds, f.To)
|
|
}
|
|
if len(clauses) > 0 {
|
|
return " AND " + strings.Join(clauses, " AND "), binds
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
var cutoff time.Time
|
|
switch period {
|
|
case "today":
|
|
cutoff = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
case "24h":
|
|
cutoff = now.Add(-24 * time.Hour)
|
|
case "7d":
|
|
cutoff = now.Add(-7 * 24 * time.Hour)
|
|
case "30d":
|
|
cutoff = now.Add(-30 * 24 * time.Hour)
|
|
default:
|
|
return "", nil
|
|
}
|
|
|
|
return " AND timestamp >= ?", []interface{}{cutoff.Format(time.RFC3339)}
|
|
}
|
|
|
|
func (s *Server) handleUsageSummary(c *gin.Context) {
|
|
var filter UsagePeriodFilter
|
|
if err := c.ShouldBindQuery(&filter); err != nil {
|
|
// ignore
|
|
}
|
|
|
|
clause, binds := filter.ToSQL()
|
|
|
|
// Total stats
|
|
var totalStats struct {
|
|
TotalRequests int `db:"total_requests"`
|
|
TotalTokens int `db:"total_tokens"`
|
|
CacheReadTokens int `db:"total_cache_read_tokens"`
|
|
CacheWriteTokens int `db:"total_cache_write_tokens"`
|
|
TotalCost float64 `db:"total_cost"`
|
|
ActiveClients int `db:"active_clients"`
|
|
}
|
|
err := s.database.Get(&totalStats, fmt.Sprintf(`
|
|
SELECT
|
|
COUNT(*) as total_requests,
|
|
COALESCE(SUM(total_tokens), 0) as total_tokens,
|
|
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens,
|
|
COALESCE(SUM(cache_write_tokens), 0) as total_cache_write_tokens,
|
|
COALESCE(SUM(cost), 0.0) as total_cost,
|
|
COUNT(DISTINCT client_id) as active_clients
|
|
FROM llm_requests
|
|
WHERE 1=1 %s
|
|
`, clause), binds...)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
|
return
|
|
}
|
|
|
|
// Today stats
|
|
var todayStats struct {
|
|
TodayRequests int `db:"today_requests"`
|
|
TodayCost float64 `db:"today_cost"`
|
|
}
|
|
today := time.Now().UTC().Format("2006-01-02")
|
|
err = s.database.Get(&todayStats, `
|
|
SELECT
|
|
COUNT(*) as today_requests,
|
|
COALESCE(SUM(cost), 0.0) as today_cost
|
|
FROM llm_requests
|
|
WHERE timestamp LIKE ?
|
|
`, today+"%")
|
|
if err != nil {
|
|
todayStats.TodayRequests = 0
|
|
todayStats.TodayCost = 0.0
|
|
}
|
|
|
|
// Error rate & Avg response time
|
|
var miscStats struct {
|
|
ErrorRate float64 `db:"error_rate"`
|
|
AvgResponseTime float64 `db:"avg_response_time"`
|
|
}
|
|
err = s.database.Get(&miscStats, `
|
|
SELECT
|
|
CASE WHEN COUNT(*) = 0 THEN 0.0 ELSE (CAST(SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS FLOAT) / COUNT(*)) * 100.0 END as error_rate,
|
|
COALESCE(AVG(duration_ms), 0.0) as avg_response_time
|
|
FROM llm_requests
|
|
`)
|
|
if err != nil {
|
|
miscStats.ErrorRate = 0.0
|
|
miscStats.AvgResponseTime = 0.0
|
|
}
|
|
|
|
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
|
"total_requests": totalStats.TotalRequests,
|
|
"total_tokens": totalStats.TotalTokens,
|
|
"total_cache_read_tokens": totalStats.CacheReadTokens,
|
|
"total_cache_write_tokens": totalStats.CacheWriteTokens,
|
|
"total_cost": totalStats.TotalCost,
|
|
"active_clients": totalStats.ActiveClients,
|
|
"today_requests": todayStats.TodayRequests,
|
|
"today_cost": todayStats.TodayCost,
|
|
"error_rate": miscStats.ErrorRate,
|
|
"avg_response_time": miscStats.AvgResponseTime,
|
|
}))
|
|
}
|
|
|
|
func (s *Server) handleTimeSeries(c *gin.Context) {
|
|
var filter UsagePeriodFilter
|
|
if err := c.ShouldBindQuery(&filter); err != nil {
|
|
// ignore
|
|
}
|
|
|
|
clause, binds := filter.ToSQL()
|
|
|
|
if clause == "" {
|
|
cutoff := time.Now().UTC().Add(-30 * 24 * time.Hour)
|
|
clause = " AND timestamp >= ?"
|
|
binds = []interface{}{cutoff.Format(time.RFC3339)}
|
|
}
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT
|
|
COALESCE(SUBSTR(timestamp, 1, 10), 'unknown') as bucket,
|
|
COUNT(*) as requests,
|
|
COALESCE(SUM(total_tokens), 0) as tokens,
|
|
COALESCE(SUM(cost), 0.0) as cost
|
|
FROM llm_requests
|
|
WHERE 1=1 %s
|
|
GROUP BY bucket
|
|
ORDER BY bucket
|
|
`, clause)
|
|
|
|
rows, err := s.database.Queryx(query, binds...)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var series []gin.H
|
|
for rows.Next() {
|
|
var bucket string
|
|
var requests int
|
|
var tokens int
|
|
var cost float64
|
|
if err := rows.Scan(&bucket, &requests, &tokens, &cost); err != nil {
|
|
continue
|
|
}
|
|
series = append(series, gin.H{
|
|
"time": bucket,
|
|
"requests": requests,
|
|
"tokens": tokens,
|
|
"cost": cost,
|
|
})
|
|
}
|
|
|
|
granularity := "day"
|
|
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
|
"series": series,
|
|
"granularity": granularity,
|
|
}))
|
|
}
|
|
|
|
func (s *Server) handleProvidersUsage(c *gin.Context) {
|
|
var filter UsagePeriodFilter
|
|
if err := c.ShouldBindQuery(&filter); err != nil {
|
|
// ignore
|
|
}
|
|
|
|
clause, binds := filter.ToSQL()
|
|
|
|
rows, err := s.database.Queryx(fmt.Sprintf(`
|
|
SELECT
|
|
COALESCE(provider, 'unknown') as provider,
|
|
COUNT(*) as requests,
|
|
COALESCE(SUM(cost), 0.0) as cost
|
|
FROM llm_requests
|
|
WHERE 1=1 %s
|
|
GROUP BY provider
|
|
`, clause), binds...)
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, SuccessResponse([]interface{}{}))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []gin.H
|
|
for rows.Next() {
|
|
var provider string
|
|
var requests int
|
|
var cost float64
|
|
if err := rows.Scan(&provider, &requests, &cost); err == nil {
|
|
results = append(results, gin.H{"provider": provider, "requests": requests, "cost": cost})
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, SuccessResponse(results))
|
|
}
|
|
|
|
func (s *Server) handleClientsUsage(c *gin.Context) {
|
|
var filter UsagePeriodFilter
|
|
if err := c.ShouldBindQuery(&filter); err != nil {
|
|
// ignore
|
|
}
|
|
|
|
clause, binds := filter.ToSQL()
|
|
|
|
rows, err := s.database.Queryx(fmt.Sprintf(`
|
|
SELECT COALESCE(client_id, 'unknown') as client_id, COUNT(*) as requests
|
|
FROM llm_requests
|
|
WHERE 1=1 %s
|
|
GROUP BY client_id
|
|
`, clause), binds...)
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, SuccessResponse([]interface{}{}))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []gin.H
|
|
for rows.Next() {
|
|
var clientID string
|
|
var requests int
|
|
if err := rows.Scan(&clientID, &requests); err == nil {
|
|
results = append(results, gin.H{"client_id": clientID, "requests": requests})
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, SuccessResponse(results))
|
|
}
|
|
|
|
func (s *Server) handleAnalyticsBreakdown(c *gin.Context) {
|
|
var filter UsagePeriodFilter
|
|
if err := c.ShouldBindQuery(&filter); err != nil {
|
|
// ignore
|
|
}
|
|
|
|
clause, binds := filter.ToSQL()
|
|
|
|
// Models breakdown
|
|
var models []struct {
|
|
Label string `json:"label"`
|
|
Value int `json:"value"`
|
|
}
|
|
mRows, err := s.database.Queryx(fmt.Sprintf("SELECT COALESCE(model, 'unknown') as label, COUNT(*) as value FROM llm_requests WHERE 1=1 %s GROUP BY model ORDER BY value DESC", clause), binds...)
|
|
if err == nil {
|
|
for mRows.Next() {
|
|
var label string
|
|
var value int
|
|
if err := mRows.Scan(&label, &value); err == nil {
|
|
models = append(models, struct {
|
|
Label string `json:"label"`
|
|
Value int `json:"value"`
|
|
}{label, value})
|
|
}
|
|
}
|
|
mRows.Close()
|
|
}
|
|
|
|
// Clients breakdown
|
|
var clients []struct {
|
|
Label string `json:"label"`
|
|
Value int `json:"value"`
|
|
}
|
|
cRows, err := s.database.Queryx(fmt.Sprintf("SELECT COALESCE(client_id, 'unknown') as label, COUNT(*) as value FROM llm_requests WHERE 1=1 %s GROUP BY client_id ORDER BY value DESC", clause), binds...)
|
|
if err == nil {
|
|
for cRows.Next() {
|
|
var label string
|
|
var value int
|
|
if err := cRows.Scan(&label, &value); err == nil {
|
|
clients = append(clients, struct {
|
|
Label string `json:"label"`
|
|
Value int `json:"value"`
|
|
}{label, value})
|
|
}
|
|
}
|
|
cRows.Close()
|
|
}
|
|
|
|
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
|
"models": models,
|
|
"clients": clients,
|
|
}))
|
|
}
|
|
|
|
func (s *Server) handleDetailedUsage(c *gin.Context) {
|
|
var filter UsagePeriodFilter
|
|
if err := c.ShouldBindQuery(&filter); err != nil {
|
|
// ignore
|
|
}
|
|
|
|
clause, binds := filter.ToSQL()
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT
|
|
COALESCE(SUBSTR(timestamp, 1, 10), 'unknown') as date,
|
|
COALESCE(client_id, 'unknown') as client,
|
|
COALESCE(provider, 'unknown') as provider,
|
|
COALESCE(model, 'unknown') as model,
|
|
COUNT(*) as requests,
|
|
COALESCE(SUM(total_tokens), 0) as tokens,
|
|
COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens,
|
|
COALESCE(SUM(cache_write_tokens), 0) as cache_write_tokens,
|
|
COALESCE(SUM(cost), 0.0) as cost
|
|
FROM llm_requests
|
|
WHERE 1=1 %s
|
|
GROUP BY date, client, provider, model
|
|
ORDER BY date DESC, cost DESC
|
|
`, clause)
|
|
|
|
rows, err := s.database.Queryx(query, binds...)
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, SuccessResponse([]interface{}{}))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []gin.H
|
|
for rows.Next() {
|
|
var date, client, provider, model string
|
|
var requests, tokens, cacheRead, cacheWrite int
|
|
var cost float64
|
|
if err := rows.Scan(&date, &client, &provider, &model, &requests, &tokens, &cacheRead, &cacheWrite, &cost); err == nil {
|
|
results = append(results, gin.H{
|
|
"date": date,
|
|
"client": client,
|
|
"provider": provider,
|
|
"model": model,
|
|
"requests": requests,
|
|
"tokens": tokens,
|
|
"cache_read_tokens": cacheRead,
|
|
"cache_write_tokens": cacheWrite,
|
|
"cost": cost,
|
|
})
|
|
}
|