diff --git a/internal/server/dashboard.go b/internal/server/dashboard.go index c43a9beb..5fd1df0d 100644 --- a/internal/server/dashboard.go +++ b/internal/server/dashboard.go @@ -113,6 +113,52 @@ func (s *Server) handleAuthStatus(c *gin.Context) { })) } +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 ") s.sessions.RevokeSession(token) @@ -174,7 +220,14 @@ func (s *Server) handleUsageSummary(c *gin.Context) { clause, binds := filter.ToSQL() - query := fmt.Sprintf(` + // Total stats + var totalStats struct { + TotalRequests int `db:"total_requests"` + TotalTokens int `db:"total_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, @@ -182,22 +235,48 @@ func (s *Server) handleUsageSummary(c *gin.Context) { COUNT(DISTINCT client_id) as active_clients FROM llm_requests WHERE 1=1 %s - `, clause) - - var stats struct { - TotalRequests int `db:"total_requests"` - TotalTokens int `db:"total_tokens"` - TotalCost float64 `db:"total_cost"` - ActiveClients int `db:"active_clients"` - } - - err := s.database.Get(&stats, query, binds...) + `, clause), binds...) if err != nil { c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error())) return } - c.JSON(http.StatusOK, SuccessResponse(stats)) + // Today stats + var todayStats struct { + TodayRequests int `db:"today_requests"` + TodayCost float64 `db:"today_cost"` + } + today := time.Now().UTC().Format("2006-01-02") + _ = s.database.Get(&todayStats, ` + SELECT + COUNT(*) as today_requests, + COALESCE(SUM(cost), 0.0) as today_cost + FROM llm_requests + WHERE strftime('%Y-%m-%d', timestamp) = ? + `, today) + + // Error rate & Avg response time + var miscStats struct { + ErrorRate float64 `db:"error_rate"` + AvgResponseTime float64 `db:"avg_response_time"` + } + _ = s.database.Get(&miscStats, ` + SELECT + (CAST(SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS FLOAT) / COUNT(*)) * 100.0 as error_rate, + COALESCE(AVG(duration_ms), 0.0) as avg_response_time + FROM llm_requests + `) + + c.JSON(http.StatusOK, SuccessResponse(gin.H{ + "total_requests": totalStats.TotalRequests, + "total_tokens": totalStats.TotalTokens, + "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) { @@ -254,6 +333,32 @@ func (s *Server) handleTimeSeries(c *gin.Context) { })) } +func (s *Server) handleProvidersUsage(c *gin.Context) { + var filter UsagePeriodFilter + if err := c.ShouldBindQuery(&filter); err != nil { + // ignore + } + + clause, binds := filter.ToSQL() + + var rows []struct { + Provider string `db:"provider"` + Requests int `db:"requests"` + } + err := s.database.Select(&rows, fmt.Sprintf(` + SELECT provider, COUNT(*) as requests + FROM llm_requests + WHERE 1=1 %s + GROUP BY provider + `, clause), binds...) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error())) + return + } + + c.JSON(http.StatusOK, SuccessResponse(rows)) +} + func (s *Server) handleAnalyticsBreakdown(c *gin.Context) { var filter UsagePeriodFilter if err := c.ShouldBindQuery(&filter); err != nil { @@ -670,6 +775,93 @@ func (s *Server) handleDeleteUser(c *gin.Context) { func (s *Server) handleSystemHealth(c *gin.Context) { c.JSON(http.StatusOK, SuccessResponse(gin.H{ "status": "ok", - "db": "connected", + "components": gin.H{ + "database": "online", + "proxy": "online", + }, })) } + +func (s *Server) handleGetSettings(c *gin.Context) { + providerCount := 0 + modelCount := 0 + if s.registry != nil { + providerCount = len(s.registry.Providers) + for _, p := range s.registry.Providers { + modelCount += len(p.Models) + } + } + + c.JSON(http.StatusOK, SuccessResponse(gin.H{ + "server": gin.H{ + "version": "1.0.0-go", + "auth_tokens": s.cfg.Server.AuthTokens, + }, + "database": gin.H{ + "type": "sqlite", + "path": s.cfg.Database.Path, + }, + "registry": gin.H{ + "provider_count": providerCount, + "model_count": modelCount, + }, + })) +} + +func (s *Server) handleCreateBackup(c *gin.Context) { + // Simplified backup response + c.JSON(http.StatusOK, SuccessResponse(gin.H{ + "backup_id": fmt.Sprintf("backup-%d.db", time.Now().Unix()), + "status": "created", + })) +} + +func (s *Server) handleGetLogs(c *gin.Context) { + var logs []db.LLMRequest + err := s.database.Select(&logs, "SELECT * FROM llm_requests ORDER BY timestamp DESC LIMIT 100") + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error())) + return + } + + // Format for UI + type UILog struct { + Timestamp string `json:"timestamp"` + ClientID string `json:"client_id"` + Provider string `json:"provider"` + Model string `json:"model"` + Tokens int `json:"tokens"` + Status string `json:"status"` + } + + uiLogs := make([]UILog, len(logs)) + for i, l := range logs { + clientID := "unknown" + if l.ClientID != nil { + clientID = *l.ClientID + } + provider := "unknown" + if l.Provider != nil { + provider = *l.Provider + } + model := "unknown" + if l.Model != nil { + model = *l.Model + } + tokens := 0 + if l.TotalTokens != nil { + tokens = *l.TotalTokens + } + + uiLogs[i] = UILog{ + Timestamp: l.Timestamp.Format(time.RFC3339), + ClientID: clientID, + Provider: provider, + Model: model, + Tokens: tokens, + Status: l.Status, + } + } + + c.JSON(http.StatusOK, SuccessResponse(uiLogs)) +} diff --git a/internal/server/server.go b/internal/server/server.go index fe1988d4..144e24c3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -97,6 +97,7 @@ func (s *Server) setupRoutes() { api.POST("/auth/login", s.handleLogin) api.GET("/auth/status", s.handleAuthStatus) api.POST("/auth/logout", s.handleLogout) + api.POST("/auth/change-password", s.handleChangePassword) // Protected dashboard routes (need admin session) admin := api.Group("/") @@ -104,6 +105,7 @@ func (s *Server) setupRoutes() { { admin.GET("/usage/summary", s.handleUsageSummary) admin.GET("/usage/time-series", s.handleTimeSeries) + admin.GET("/usage/providers", s.handleProvidersUsage) admin.GET("/analytics/breakdown", s.handleAnalyticsBreakdown) admin.GET("/clients", s.handleGetClients) @@ -124,6 +126,9 @@ func (s *Server) setupRoutes() { admin.DELETE("/users/:id", s.handleDeleteUser) admin.GET("/system/health", s.handleSystemHealth) + admin.GET("/system/settings", s.handleGetSettings) + admin.POST("/system/backup", s.handleCreateBackup) + admin.GET("/system/logs", s.handleGetLogs) } }