chore: initial import
This commit is contained in:
43
internal/handler/alert_handler.go
Normal file
43
internal/handler/alert_handler.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/company/ai-ops/internal/domain/repository"
|
||||
"github.com/company/ai-ops/pkg/errors"
|
||||
"github.com/company/ai-ops/pkg/response"
|
||||
)
|
||||
|
||||
// AlertHandler 是告警事件 HTTP 处理器
|
||||
type AlertHandler struct {
|
||||
repo repository.AlertRepository
|
||||
}
|
||||
|
||||
func NewAlertHandler(repo repository.AlertRepository) *AlertHandler {
|
||||
return &AlertHandler{repo: repo}
|
||||
}
|
||||
|
||||
func (h *AlertHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/v1/ai-ops/alerts", h.ListAlerts)
|
||||
}
|
||||
|
||||
func (h *AlertHandler) ListAlerts(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
status := query.Get("status")
|
||||
page, _ := strconv.Atoi(query.Get("page"))
|
||||
pageSize, _ := strconv.Atoi(query.Get("page_size"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
events, total, err := h.repo.ListEvents(r.Context(), status, page, pageSize)
|
||||
if err != nil {
|
||||
response.Error(w, errors.Wrap(err, errors.ErrInternal))
|
||||
return
|
||||
}
|
||||
response.Success(w, map[string]any{"items": events, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
55
internal/handler/audit_handler.go
Normal file
55
internal/handler/audit_handler.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/company/ai-ops/internal/service"
|
||||
"github.com/company/ai-ops/pkg/errors"
|
||||
"github.com/company/ai-ops/pkg/response"
|
||||
)
|
||||
|
||||
// AuditHandler 是审计日志 HTTP 处理器
|
||||
type AuditHandler struct {
|
||||
service *service.AuditService
|
||||
}
|
||||
|
||||
func NewAuditHandler(s *service.AuditService) *AuditHandler {
|
||||
return &AuditHandler{service: s}
|
||||
}
|
||||
|
||||
func (h *AuditHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/v1/ai-ops/audits", h.ListAudits)
|
||||
mux.HandleFunc("POST /api/v1/ai-ops/audits/{id}/rollback", h.Rollback)
|
||||
}
|
||||
|
||||
func (h *AuditHandler) ListAudits(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
objectType := query.Get("object_type")
|
||||
objectID := query.Get("object_id")
|
||||
page, _ := strconv.Atoi(query.Get("page"))
|
||||
pageSize, _ := strconv.Atoi(query.Get("page_size"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
logs, total, err := h.service.List(r.Context(), objectType, objectID, page, pageSize)
|
||||
if err != nil {
|
||||
response.Error(w, errors.Wrap(err, errors.ErrInternal))
|
||||
return
|
||||
}
|
||||
response.Success(w, map[string]any{"items": logs, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
func (h *AuditHandler) Rollback(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
log, err := h.service.Rollback(r.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(w, errors.Wrap(err, errors.ErrBadRequest).WithDetail(map[string]any{"error": err.Error()}))
|
||||
return
|
||||
}
|
||||
response.Success(w, log)
|
||||
}
|
||||
59
internal/handler/auth_handler.go
Normal file
59
internal/handler/auth_handler.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/company/ai-ops/internal/service"
|
||||
"github.com/company/ai-ops/pkg/errors"
|
||||
"github.com/company/ai-ops/pkg/response"
|
||||
)
|
||||
|
||||
// AuthHandler 是认证 HTTP 处理器
|
||||
type AuthHandler struct {
|
||||
authSvc *service.AuthService
|
||||
}
|
||||
|
||||
func NewAuthHandler(authSvc *service.AuthService) *AuthHandler {
|
||||
return &AuthHandler{authSvc: authSvc}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/v1/ai-ops/login", h.Login)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
response.Error(w, errors.ErrBadRequest.WithDetail(map[string]any{"error": err.Error()}))
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 实现真实的用户验证(当前为简化实现)
|
||||
if req.Username == "" || req.Password == "" {
|
||||
response.Error(w, errors.ErrBadRequest.WithDetail(map[string]any{"error": "username and password required"}))
|
||||
return
|
||||
}
|
||||
|
||||
// 默认角色为 viewer
|
||||
role := "viewer"
|
||||
if req.Username == "admin" {
|
||||
role = "admin"
|
||||
} else if req.Username == "ops" {
|
||||
role = "operator"
|
||||
}
|
||||
|
||||
token, err := h.authSvc.IssueToken(req.Username, role)
|
||||
if err != nil {
|
||||
response.Error(w, errors.Wrap(err, errors.ErrInternal))
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(w, map[string]any{
|
||||
"token": token,
|
||||
"expires_in": 28800,
|
||||
"role": role,
|
||||
})
|
||||
}
|
||||
97
internal/handler/channel_handler.go
Normal file
97
internal/handler/channel_handler.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/company/ai-ops/internal/domain/model"
|
||||
"github.com/company/ai-ops/internal/service"
|
||||
"github.com/company/ai-ops/pkg/errors"
|
||||
"github.com/company/ai-ops/pkg/response"
|
||||
)
|
||||
|
||||
// ChannelHandler 是通知渠道 HTTP 处理器
|
||||
type ChannelHandler struct {
|
||||
service *service.ChannelService
|
||||
}
|
||||
|
||||
func NewChannelHandler(s *service.ChannelService) *ChannelHandler {
|
||||
return &ChannelHandler{service: s}
|
||||
}
|
||||
|
||||
func (h *ChannelHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/v1/ai-ops/channels", h.ListChannels)
|
||||
mux.HandleFunc("GET /api/v1/ai-ops/channels/{id}", h.GetChannel)
|
||||
mux.HandleFunc("POST /api/v1/ai-ops/channels", h.CreateChannel)
|
||||
mux.HandleFunc("PUT /api/v1/ai-ops/channels/{id}", h.UpdateChannel)
|
||||
mux.HandleFunc("DELETE /api/v1/ai-ops/channels/{id}", h.DeleteChannel)
|
||||
mux.HandleFunc("POST /api/v1/ai-ops/channels/test", h.TestChannel)
|
||||
}
|
||||
|
||||
func (h *ChannelHandler) ListChannels(w http.ResponseWriter, r *http.Request) {
|
||||
channels, err := h.service.List(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, errors.Wrap(err, errors.ErrInternal))
|
||||
return
|
||||
}
|
||||
response.Success(w, channels)
|
||||
}
|
||||
|
||||
func (h *ChannelHandler) GetChannel(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
ch, err := h.service.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(w, errors.Wrap(err, errors.ErrNotFound))
|
||||
return
|
||||
}
|
||||
response.Success(w, ch)
|
||||
}
|
||||
|
||||
func (h *ChannelHandler) CreateChannel(w http.ResponseWriter, r *http.Request) {
|
||||
var ch model.NotificationChannel
|
||||
if err := decodeJSON(r, &ch); err != nil {
|
||||
response.Error(w, errors.ErrBadRequest.WithDetail(map[string]any{"error": err.Error()}))
|
||||
return
|
||||
}
|
||||
if err := h.service.Create(r.Context(), &ch); err != nil {
|
||||
response.Error(w, errors.Wrap(err, errors.ErrBadRequest))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
response.Success(w, ch)
|
||||
}
|
||||
|
||||
func (h *ChannelHandler) UpdateChannel(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var ch model.NotificationChannel
|
||||
if err := decodeJSON(r, &ch); err != nil {
|
||||
response.Error(w, errors.ErrBadRequest.WithDetail(map[string]any{"error": err.Error()}))
|
||||
return
|
||||
}
|
||||
ch.ID = id
|
||||
if err := h.service.Update(r.Context(), &ch); err != nil {
|
||||
response.Error(w, errors.Wrap(err, errors.ErrBadRequest))
|
||||
return
|
||||
}
|
||||
response.Success(w, ch)
|
||||
}
|
||||
|
||||
func (h *ChannelHandler) DeleteChannel(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := h.service.Delete(r.Context(), id); err != nil {
|
||||
response.Error(w, errors.Wrap(err, errors.ErrInternal))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *ChannelHandler) TestChannel(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
response.Error(w, errors.ErrBadRequest.WithDetail(map[string]any{"error": err.Error()}))
|
||||
return
|
||||
}
|
||||
response.Success(w, map[string]any{"ok": true})
|
||||
}
|
||||
391
internal/handler/core_handlers_test.go
Normal file
391
internal/handler/core_handlers_test.go
Normal file
@@ -0,0 +1,391 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/company/ai-ops/internal/config"
|
||||
"github.com/company/ai-ops/internal/database"
|
||||
"github.com/company/ai-ops/internal/domain/model"
|
||||
"github.com/company/ai-ops/internal/service"
|
||||
)
|
||||
|
||||
type fakeHandlerAlertRepo struct {
|
||||
rules []model.AlertRule
|
||||
events []model.AlertEvent
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *fakeHandlerAlertRepo) GetOpenCount(context.Context) (*model.AlertCount, error) {
|
||||
return &model.AlertCount{}, r.err
|
||||
}
|
||||
func (r *fakeHandlerAlertRepo) ListRules(context.Context) ([]model.AlertRule, error) {
|
||||
return r.rules, r.err
|
||||
}
|
||||
func (r *fakeHandlerAlertRepo) GetRuleByID(_ context.Context, id string) (*model.AlertRule, error) {
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
return &model.AlertRule{ID: id, Name: "rule"}, nil
|
||||
}
|
||||
func (r *fakeHandlerAlertRepo) CreateRule(context.Context, *model.AlertRule) error { return r.err }
|
||||
func (r *fakeHandlerAlertRepo) UpdateRule(context.Context, *model.AlertRule) error { return r.err }
|
||||
func (r *fakeHandlerAlertRepo) DeleteRule(context.Context, string) error { return r.err }
|
||||
func (r *fakeHandlerAlertRepo) ListEvents(context.Context, string, int, int) ([]model.AlertEvent, int, error) {
|
||||
return r.events, len(r.events), r.err
|
||||
}
|
||||
func (r *fakeHandlerAlertRepo) CreateEvent(context.Context, *model.AlertEvent) error { return r.err }
|
||||
func (r *fakeHandlerAlertRepo) CreateEventWithAggregation(_ context.Context, e *model.AlertEvent, _ time.Duration, _ int) (*model.AlertEvent, error) {
|
||||
return e, r.err
|
||||
}
|
||||
func (r *fakeHandlerAlertRepo) UpdateEventStatus(context.Context, string, string) error { return r.err }
|
||||
func (r *fakeHandlerAlertRepo) EscalateEvent(context.Context, string, string) error { return r.err }
|
||||
|
||||
type fakeHandlerChannelRepo struct {
|
||||
channels []model.NotificationChannel
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *fakeHandlerChannelRepo) List(context.Context) ([]model.NotificationChannel, error) {
|
||||
return r.channels, r.err
|
||||
}
|
||||
func (r *fakeHandlerChannelRepo) GetByID(_ context.Context, id string) (*model.NotificationChannel, error) {
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
return &model.NotificationChannel{ID: id, Name: "hook"}, nil
|
||||
}
|
||||
func (r *fakeHandlerChannelRepo) Create(context.Context, *model.NotificationChannel) error {
|
||||
return r.err
|
||||
}
|
||||
func (r *fakeHandlerChannelRepo) Update(context.Context, *model.NotificationChannel) error {
|
||||
return r.err
|
||||
}
|
||||
func (r *fakeHandlerChannelRepo) Delete(context.Context, string) error { return r.err }
|
||||
|
||||
type fakeHandlerLogRepo struct {
|
||||
logs []model.RequestLog
|
||||
total int
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *fakeHandlerLogRepo) Query(context.Context, model.LogQueryFilter) ([]model.RequestLog, int, error) {
|
||||
return r.logs, r.total, r.err
|
||||
}
|
||||
|
||||
func TestAuthHandlerLoginRolesAndValidation(t *testing.T) {
|
||||
h := NewAuthHandler(service.NewAuthService("secret"))
|
||||
|
||||
cases := []struct{ username, wantRole string }{{"admin", "admin"}, {"ops", "operator"}, {"alice", "viewer"}}
|
||||
for _, tc := range cases {
|
||||
w := httptest.NewRecorder()
|
||||
h.Login(w, httptest.NewRequest(http.MethodPost, "/api/v1/ai-ops/login", strings.NewReader(`{"username":"`+tc.username+`","password":"pw"}`)))
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"role":"`+tc.wantRole+`"`) || !strings.Contains(w.Body.String(), `"token"`) {
|
||||
t.Fatalf("login %s failed: status=%d body=%s", tc.username, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.Login(w, httptest.NewRequest(http.MethodPost, "/api/v1/ai-ops/login", strings.NewReader(`{"username":"","password":""}`)))
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid login status = %d", w.Code)
|
||||
}
|
||||
|
||||
bad := httptest.NewRecorder()
|
||||
h.Login(bad, httptest.NewRequest(http.MethodPost, "/api/v1/ai-ops/login", strings.NewReader(`{`)))
|
||||
if bad.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad json status = %d", bad.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthAndDashboardHandlers(t *testing.T) {
|
||||
health := NewHealthHandler()
|
||||
w := httptest.NewRecorder()
|
||||
health.Health(w, httptest.NewRequest(http.MethodGet, "/actuator/health", nil))
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"status":"UP"`) {
|
||||
t.Fatalf("health = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
live := httptest.NewRecorder()
|
||||
health.Live(live, httptest.NewRequest(http.MethodGet, "/actuator/health/live", nil))
|
||||
if live.Code != http.StatusOK {
|
||||
t.Fatalf("live = %d", live.Code)
|
||||
}
|
||||
|
||||
ready := httptest.NewRecorder()
|
||||
health.Ready(ready, httptest.NewRequest(http.MethodGet, "/actuator/health/ready", nil))
|
||||
if ready.Code != http.StatusServiceUnavailable || !strings.Contains(ready.Body.String(), `"status":"DOWN"`) {
|
||||
t.Fatalf("ready = %d %s", ready.Code, ready.Body.String())
|
||||
}
|
||||
|
||||
dash := httptest.NewRecorder()
|
||||
NewDashboardHandler().Dashboard(dash, httptest.NewRequest(http.MethodGet, "/ops/dashboard", nil))
|
||||
if dash.Code != http.StatusOK || !strings.Contains(dash.Body.String(), "AI-Ops 运维看板") {
|
||||
t.Fatalf("dashboard = %d", dash.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleHandlerCRUDHappyAndErrorPaths(t *testing.T) {
|
||||
repo := &fakeHandlerAlertRepo{rules: []model.AlertRule{{ID: "r1", Name: "rule"}}}
|
||||
h := NewRuleHandler(service.NewRuleService(repo))
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
body string
|
||||
want int
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/ai-ops/rules", "", http.StatusOK},
|
||||
{http.MethodGet, "/api/v1/ai-ops/rules/r1", "", http.StatusOK},
|
||||
{http.MethodPost, "/api/v1/ai-ops/rules", `{"id":"r2","name":"latency","metric_name":"p99"}`, http.StatusCreated},
|
||||
{http.MethodPut, "/api/v1/ai-ops/rules/r2", `{"name":"latency","metric_name":"p99"}`, http.StatusOK},
|
||||
{http.MethodDelete, "/api/v1/ai-ops/rules/r2", "", http.StatusNoContent},
|
||||
{http.MethodPost, "/api/v1/ai-ops/rules", `{`, http.StatusBadRequest},
|
||||
{http.MethodPost, "/api/v1/ai-ops/rules", `{}`, http.StatusBadRequest},
|
||||
} {
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)))
|
||||
if w.Code != tc.want {
|
||||
t.Fatalf("%s %s status=%d want=%d body=%s", tc.method, tc.path, w.Code, tc.want, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
errHandler := NewRuleHandler(service.NewRuleService(&fakeHandlerAlertRepo{err: errors.New("db")}))
|
||||
errMux := http.NewServeMux()
|
||||
errHandler.RegisterRoutes(errMux)
|
||||
w := httptest.NewRecorder()
|
||||
errMux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/rules", nil))
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("error list status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHandlerCRUDHappyAndErrorPaths(t *testing.T) {
|
||||
h := NewChannelHandler(service.NewChannelService(&fakeHandlerChannelRepo{channels: []model.NotificationChannel{{ID: "c1", Name: "hook"}}}))
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
for _, tc := range []struct {
|
||||
method, path, body string
|
||||
want int
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/ai-ops/channels", "", http.StatusOK},
|
||||
{http.MethodGet, "/api/v1/ai-ops/channels/c1", "", http.StatusOK},
|
||||
{http.MethodPost, "/api/v1/ai-ops/channels", `{"name":"hook","channel_type":"webhook"}`, http.StatusCreated},
|
||||
{http.MethodPut, "/api/v1/ai-ops/channels/c1", `{"name":"hook","channel_type":"webhook"}`, http.StatusOK},
|
||||
{http.MethodDelete, "/api/v1/ai-ops/channels/c1", "", http.StatusNoContent},
|
||||
{http.MethodPost, "/api/v1/ai-ops/channels/test", `{"channel_id":"c1","message":"hello"}`, http.StatusOK},
|
||||
{http.MethodPost, "/api/v1/ai-ops/channels", `{}`, http.StatusBadRequest},
|
||||
{http.MethodPost, "/api/v1/ai-ops/channels/test", `{`, http.StatusBadRequest},
|
||||
} {
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)))
|
||||
if w.Code != tc.want {
|
||||
t.Fatalf("%s %s status=%d want=%d body=%s", tc.method, tc.path, w.Code, tc.want, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertAndLogHandlers(t *testing.T) {
|
||||
alertHandler := NewAlertHandler(&fakeHandlerAlertRepo{events: []model.AlertEvent{{ID: "e1", Status: "triggered"}}})
|
||||
alertMux := http.NewServeMux()
|
||||
alertHandler.RegisterRoutes(alertMux)
|
||||
aw := httptest.NewRecorder()
|
||||
alertMux.ServeHTTP(aw, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/alerts?status=triggered&page=2&page_size=5", nil))
|
||||
if aw.Code != http.StatusOK || !strings.Contains(aw.Body.String(), `"items"`) {
|
||||
t.Fatalf("alerts = %d %s", aw.Code, aw.Body.String())
|
||||
}
|
||||
|
||||
logHandler := NewLogHandler(service.NewLogService(&fakeHandlerLogRepo{logs: []model.RequestLog{{Timestamp: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), Service: "api", Path: "/v1", Method: "GET", StatusCode: 200}}, total: 1}))
|
||||
logMux := http.NewServeMux()
|
||||
logHandler.RegisterRoutes(logMux)
|
||||
lw := httptest.NewRecorder()
|
||||
logMux.ServeHTTP(lw, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/logs?page=2&page_size=5&status_code=200", nil))
|
||||
if lw.Code != http.StatusOK || !strings.Contains(lw.Body.String(), `"total_pages"`) {
|
||||
t.Fatalf("logs = %d %s", lw.Code, lw.Body.String())
|
||||
}
|
||||
|
||||
csv := httptest.NewRecorder()
|
||||
logMux.ServeHTTP(csv, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/logs/export", nil))
|
||||
if csv.Code != http.StatusOK || !strings.Contains(csv.Body.String(), "时间,服务名") {
|
||||
t.Fatalf("csv = %d %s", csv.Code, csv.Body.String())
|
||||
}
|
||||
|
||||
badCSV := httptest.NewRecorder()
|
||||
badLogHandler := NewLogHandler(service.NewLogService(&fakeHandlerLogRepo{err: errors.New("export failed")}))
|
||||
badLogMux := http.NewServeMux()
|
||||
badLogHandler.RegisterRoutes(badLogMux)
|
||||
badLogMux.ServeHTTP(badCSV, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/logs/export?start=bad&end=bad&status_code=bad", nil))
|
||||
if badCSV.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("csv error = %d %s", badCSV.Code, badCSV.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type fakeHandlerMetricRepo struct {
|
||||
realtime *model.RealtimeMetrics
|
||||
points []model.MetricPoint
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *fakeHandlerMetricRepo) GetRealtime(context.Context) (*model.RealtimeMetrics, error) {
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
return r.realtime, nil
|
||||
}
|
||||
func (r *fakeHandlerMetricRepo) Query(context.Context, model.MetricQueryRequest) ([]model.MetricPoint, error) {
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
return r.points, nil
|
||||
}
|
||||
func (r *fakeHandlerMetricRepo) GetLatest(context.Context, string, string) (*model.MetricPoint, error) {
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
return &model.MetricPoint{Value: 1}, nil
|
||||
}
|
||||
|
||||
func TestRegisterRoutesForSmallHandlers(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
NewAuthHandler(service.NewAuthService("secret")).RegisterRoutes(mux)
|
||||
NewDashboardHandler().RegisterRoutes(mux)
|
||||
NewHealthHandler().RegisterRoutes(mux)
|
||||
NewHealingHandler().RegisterRoutes(mux)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/healings", nil))
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"total":0`) {
|
||||
t.Fatalf("healings = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
one := httptest.NewRecorder()
|
||||
mux.ServeHTTP(one, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/healings/h1", nil))
|
||||
if one.Code != http.StatusOK || !strings.Contains(one.Body.String(), `"id":"h1"`) {
|
||||
t.Fatalf("healing = %d %s", one.Code, one.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricHandlerRoutesAndErrors(t *testing.T) {
|
||||
metricRepo := &fakeHandlerMetricRepo{realtime: &model.RealtimeMetrics{QPS: 9}, points: []model.MetricPoint{{Name: "qps", Value: 1}}}
|
||||
alertRepo := &fakeHandlerAlertRepo{}
|
||||
h := NewMetricHandler(service.NewMetricService(metricRepo, alertRepo))
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/v1/ai-ops/metrics/realtime",
|
||||
"/api/v1/ai-ops/metrics/suppliers/count",
|
||||
"/api/v1/ai-ops/alerts/open/count",
|
||||
"/api/v1/ai-ops/metrics/query?source=prom&name=qps&start=2026-01-01T00:00:00Z&end=2026-01-01T01:00:00Z",
|
||||
} {
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%s status=%d body=%s", path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
errHandler := NewMetricHandler(service.NewMetricService(&fakeHandlerMetricRepo{err: errors.New("metrics down")}, &fakeHandlerAlertRepo{err: errors.New("alerts down")}))
|
||||
errMux := http.NewServeMux()
|
||||
errHandler.RegisterRoutes(errMux)
|
||||
for _, path := range []string{"/api/v1/ai-ops/metrics/realtime", "/api/v1/ai-ops/metrics/query", "/api/v1/ai-ops/alerts/open/count"} {
|
||||
w := httptest.NewRecorder()
|
||||
errMux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("%s status=%d", path, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupHandlerAuditDB(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if database.Pool == nil {
|
||||
ports := []int{15432, 5432}
|
||||
var lastErr error
|
||||
for _, port := range ports {
|
||||
lastErr = database.Init(config.DatabaseConfig{Host: "localhost", Port: port, User: "aiops", Password: "aiops123", DBName: "ai_ops", SSLMode: "disable", PoolSize: 4})
|
||||
if lastErr == nil {
|
||||
break
|
||||
}
|
||||
database.Close()
|
||||
database.Pool = nil
|
||||
}
|
||||
if lastErr != nil {
|
||||
t.Skipf("PostgreSQL integration database not available: %v", lastErr)
|
||||
}
|
||||
}
|
||||
if _, err := database.Pool.Exec(ctx, `SELECT pg_advisory_lock(424242001)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Pool.Exec(ctx, `SELECT pg_advisory_unlock(424242001)`)
|
||||
files, err := filepath.Glob(filepath.Join("..", "..", "tech", "migrations", "*.up.sql"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sort.Strings(files)
|
||||
for _, f := range files {
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.Pool.Exec(ctx, string(b)); err != nil {
|
||||
t.Fatalf("apply migration %s: %v", f, err)
|
||||
}
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func handlerUUID(t *testing.T) string {
|
||||
t.Helper()
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return hex.EncodeToString(b[0:4]) + "-" + hex.EncodeToString(b[4:6]) + "-" + hex.EncodeToString(b[6:8]) + "-" + hex.EncodeToString(b[8:10]) + "-" + hex.EncodeToString(b[10:16])
|
||||
}
|
||||
|
||||
func TestAuditHandlerListAndRollback(t *testing.T) {
|
||||
ctx := setupHandlerAuditDB(t)
|
||||
svc := service.NewAuditService()
|
||||
id := handlerUUID(t)
|
||||
defer database.Pool.Exec(ctx, `DELETE FROM ai_ops_audits WHERE id=$1 OR parent_audit_id=$1 OR object_id=$1`, id)
|
||||
if err := svc.Record(ctx, &service.AuditLog{ID: id, TenantID: "tenant", ObjectType: "rule", ObjectID: id, Action: "update", BeforeState: map[string]any{"enabled": false}, AfterState: map[string]any{"enabled": true}, RequestID: "req", ResultCode: "SUCCESS", SourceIP: "127.0.0.1", ActorID: "actor", RiskLevel: "normal"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := NewAuditHandler(svc)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
list := httptest.NewRecorder()
|
||||
mux.ServeHTTP(list, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/audits?object_type=rule&object_id="+id+"&page=0&page_size=999", nil))
|
||||
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), id) {
|
||||
t.Fatalf("list audits = %d %s", list.Code, list.Body.String())
|
||||
}
|
||||
|
||||
rollback := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rollback, httptest.NewRequest(http.MethodPost, "/api/v1/ai-ops/audits/"+id+"/rollback", nil))
|
||||
if rollback.Code != http.StatusOK || !strings.Contains(rollback.Body.String(), `"action":"rollback"`) {
|
||||
t.Fatalf("rollback = %d %s", rollback.Code, rollback.Body.String())
|
||||
}
|
||||
|
||||
missing := httptest.NewRecorder()
|
||||
mux.ServeHTTP(missing, httptest.NewRequest(http.MethodPost, "/api/v1/ai-ops/audits/"+handlerUUID(t)+"/rollback", nil))
|
||||
if missing.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing rollback status = %d", missing.Code)
|
||||
}
|
||||
}
|
||||
118
internal/handler/dashboard_handler.go
Normal file
118
internal/handler/dashboard_handler.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// DashboardHandler 是前端页面路由处理器
|
||||
type DashboardHandler struct {
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
func NewDashboardHandler() *DashboardHandler {
|
||||
tmpl := template.Must(template.New("dashboard").Parse(dashboardHTML))
|
||||
return &DashboardHandler{templates: tmpl}
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册页面路由
|
||||
func (h *DashboardHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /ops/dashboard", h.Dashboard)
|
||||
mux.HandleFunc("GET /ops/dashboard/logs", h.Dashboard)
|
||||
mux.HandleFunc("GET /ops/dashboard/rules", h.Dashboard)
|
||||
mux.HandleFunc("GET /ops/dashboard/alerts", h.Dashboard)
|
||||
mux.HandleFunc("GET /ops/dashboard/channels", h.Dashboard)
|
||||
}
|
||||
|
||||
// Dashboard 首页
|
||||
func (h *DashboardHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = h.templates.ExecuteTemplate(w, "dashboard", nil)
|
||||
}
|
||||
|
||||
const dashboardHTML = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>AI-Ops 运维看板</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; }
|
||||
header { padding: 18px 28px; background: #111827; border-bottom: 1px solid #334155; display:flex; justify-content:space-between; align-items:center; }
|
||||
main { padding: 24px; display: grid; gap: 18px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); gap: 14px; }
|
||||
.card { background:#111827; border:1px solid #334155; border-radius:12px; padding:16px; box-shadow:0 10px 24px rgba(0,0,0,.18); }
|
||||
.metric { font-size: 28px; font-weight: 700; color:#38bdf8; margin-top:8px; }
|
||||
button, input { border-radius:8px; border:1px solid #475569; background:#0b1220; color:#e2e8f0; padding:8px 10px; }
|
||||
button { cursor:pointer; background:#2563eb; border-color:#2563eb; }
|
||||
table { width:100%; border-collapse: collapse; font-size: 14px; }
|
||||
th, td { border-bottom: 1px solid #334155; padding: 8px; text-align:left; vertical-align: top; }
|
||||
th { color:#93c5fd; }
|
||||
.muted { color:#94a3b8; }
|
||||
.row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
.error { color:#fca5a5; white-space:pre-wrap; }
|
||||
code { color:#bae6fd; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div><strong>AI-Ops 运维看板</strong><span class="muted"> · 规则 / 事件 / 渠道 / 日志</span></div>
|
||||
<div class="row"><input id="username" placeholder="admin" value="admin"><input id="password" type="password" placeholder="admin" value="admin"><button onclick="login()">登录</button><button onclick="loadAll()">刷新</button></div>
|
||||
</header>
|
||||
<main>
|
||||
<section class="grid">
|
||||
<div class="card">QPS<div id="qps" class="metric">-</div></div>
|
||||
<div class="card">平均延迟<div id="avg" class="metric">-</div></div>
|
||||
<div class="card">P99<div id="p99" class="metric">-</div></div>
|
||||
<div class="card">错误率<div id="err" class="metric">-</div></div>
|
||||
</section>
|
||||
<section class="card"><h3>告警事件</h3><div id="alerts"></div></section>
|
||||
<section class="card"><h3>告警规则</h3><div id="rules"></div></section>
|
||||
<section class="card"><h3>通知渠道</h3><div id="channels"></div></section>
|
||||
<section class="card"><h3>日志</h3><div id="logs"></div></section>
|
||||
<section class="card error" id="error"></section>
|
||||
</main>
|
||||
<script>
|
||||
const api = '/api/v1/ai-ops';
|
||||
function token(){ return localStorage.getItem('ai_ops_token') || ''; }
|
||||
function setError(e){ document.getElementById('error').textContent = e ? String(e) : ''; }
|
||||
async function login(){
|
||||
setError('');
|
||||
const res = await fetch(api + '/login', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({username: username.value, password: password.value})});
|
||||
const data = await res.json();
|
||||
const t = data?.data?.token || data?.token;
|
||||
if(!res.ok || !t){ setError(JSON.stringify(data)); return; }
|
||||
localStorage.setItem('ai_ops_token', t);
|
||||
await loadAll();
|
||||
}
|
||||
async function get(path){
|
||||
const res = await fetch(api + path, {headers:{Authorization:'Bearer ' + token()}});
|
||||
const data = await res.json();
|
||||
if(!res.ok) throw new Error(path + ' ' + JSON.stringify(data));
|
||||
return data.data ?? data;
|
||||
}
|
||||
function table(rows, cols){
|
||||
if(!Array.isArray(rows) || rows.length === 0) return '<p class="muted">暂无数据</p>';
|
||||
return '<table><thead><tr>'+cols.map(c=>'<th>'+c[0]+'</th>').join('')+'</tr></thead><tbody>'+rows.map(r=>'<tr>'+cols.map(c=>'<td>'+escapeHtml(String(r[c[1]] ?? ''))+'</td>').join('')+'</tr>').join('')+'</tbody></table>';
|
||||
}
|
||||
function escapeHtml(s){ return s.replace(/[&<>"]/g, m=>({'&':'&','<':'<','>':'>','"':'"'}[m])); }
|
||||
async function loadAll(){
|
||||
try{
|
||||
setError('');
|
||||
const m = await get('/metrics/realtime');
|
||||
qps.textContent = m.qps ?? '-'; avg.textContent = (m.avg_latency_ms ?? '-') + 'ms'; p99.textContent = (m.p99_latency_ms ?? '-') + 'ms'; err.textContent = m.error_rate ?? '-';
|
||||
const ev = await get('/alerts?page=1&page_size=20');
|
||||
alerts.innerHTML = table(ev.items || [], [['级别','level'],['资源','resource_id'],['状态','status'],['聚合','is_aggregated'],['数量','aggregated_count'],['开始时间','started_at']]);
|
||||
const rs = await get('/rules');
|
||||
rules.innerHTML = table(rs || [], [['名称','name'],['指标','metric_name'],['条件','threshold_type'],['阈值','threshold_value'],['级别','level'],['启用','enabled']]);
|
||||
const cs = await get('/channels');
|
||||
channels.innerHTML = table(cs || [], [['名称','name'],['类型','channel_type'],['优先级','priority'],['启用','enabled']]);
|
||||
const lg = await get('/logs?page=1&page_size=20');
|
||||
logs.innerHTML = table(lg.items || [], [['服务','service'],['级别','level'],['消息','message'],['时间','timestamp']]);
|
||||
}catch(e){ setError(e); }
|
||||
}
|
||||
if(token()) loadAll();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
29
internal/handler/healing_handler.go
Normal file
29
internal/handler/healing_handler.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/company/ai-ops/pkg/response"
|
||||
)
|
||||
|
||||
// HealingHandler 是自愈管理 HTTP 处理器
|
||||
type HealingHandler struct{}
|
||||
|
||||
func NewHealingHandler() *HealingHandler {
|
||||
return &HealingHandler{}
|
||||
}
|
||||
|
||||
func (h *HealingHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/v1/ai-ops/healings", h.ListHealings)
|
||||
mux.HandleFunc("GET /api/v1/ai-ops/healings/{id}", h.GetHealing)
|
||||
}
|
||||
|
||||
func (h *HealingHandler) ListHealings(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: 实现列表查询
|
||||
response.Success(w, map[string]any{"items": []any{}, "total": 0})
|
||||
}
|
||||
|
||||
func (h *HealingHandler) GetHealing(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
response.Success(w, map[string]any{"id": id, "status": "pending"})
|
||||
}
|
||||
62
internal/handler/health_handler.go
Normal file
62
internal/handler/health_handler.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/company/ai-ops/internal/database"
|
||||
"github.com/company/ai-ops/internal/redis"
|
||||
"github.com/company/ai-ops/pkg/response"
|
||||
)
|
||||
|
||||
// HealthHandler 是健康检查 HTTP 处理器
|
||||
type HealthHandler struct{}
|
||||
|
||||
func NewHealthHandler() *HealthHandler {
|
||||
return &HealthHandler{}
|
||||
}
|
||||
|
||||
func (h *HealthHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /actuator/health", h.Health)
|
||||
mux.HandleFunc("GET /actuator/health/live", h.Live)
|
||||
mux.HandleFunc("GET /actuator/health/ready", h.Ready)
|
||||
}
|
||||
|
||||
func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) {
|
||||
response.Success(w, map[string]any{
|
||||
"status": "UP",
|
||||
"components": map[string]any{
|
||||
"self": map[string]any{"status": "UP"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HealthHandler) Live(w http.ResponseWriter, r *http.Request) {
|
||||
response.Success(w, map[string]any{"status": "UP"})
|
||||
}
|
||||
|
||||
func (h *HealthHandler) Ready(w http.ResponseWriter, r *http.Request) {
|
||||
status := "UP"
|
||||
components := map[string]any{
|
||||
"self": map[string]any{"status": "UP"},
|
||||
}
|
||||
|
||||
// 检查 DB 连接
|
||||
if database.Pool == nil {
|
||||
status = "DOWN"
|
||||
components["database"] = map[string]any{"status": "DOWN", "detail": "not initialized"}
|
||||
} else {
|
||||
components["database"] = map[string]any{"status": "UP"}
|
||||
}
|
||||
|
||||
// 检查 Redis 连接
|
||||
if redis.Client == nil {
|
||||
components["redis"] = map[string]any{"status": "DOWN", "detail": "not initialized"}
|
||||
} else {
|
||||
components["redis"] = map[string]any{"status": "UP"}
|
||||
}
|
||||
|
||||
if status == "DOWN" {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
response.Success(w, map[string]any{"status": status, "components": components})
|
||||
}
|
||||
109
internal/handler/log_handler.go
Normal file
109
internal/handler/log_handler.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/company/ai-ops/internal/domain/model"
|
||||
"github.com/company/ai-ops/internal/service"
|
||||
"github.com/company/ai-ops/pkg/errors"
|
||||
"github.com/company/ai-ops/pkg/response"
|
||||
)
|
||||
|
||||
// LogHandler 是日志 HTTP 处理器
|
||||
type LogHandler struct {
|
||||
service *service.LogService
|
||||
}
|
||||
|
||||
func NewLogHandler(s *service.LogService) *LogHandler {
|
||||
return &LogHandler{service: s}
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册日志相关路由
|
||||
func (h *LogHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/v1/ai-ops/logs", h.QueryLogs)
|
||||
mux.HandleFunc("GET /api/v1/ai-ops/logs/export", h.ExportLogs)
|
||||
}
|
||||
|
||||
// QueryLogs 日志查询
|
||||
func (h *LogHandler) QueryLogs(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
|
||||
filter := model.LogQueryFilter{
|
||||
Service: query.Get("service"),
|
||||
Path: query.Get("path"),
|
||||
UserID: query.Get("user_id"),
|
||||
SupplierID: query.Get("supplier_id"),
|
||||
}
|
||||
|
||||
if startStr := query.Get("start"); startStr != "" {
|
||||
if t, err := time.Parse(time.RFC3339, startStr); err == nil {
|
||||
filter.StartTime = &t
|
||||
}
|
||||
}
|
||||
if endStr := query.Get("end"); endStr != "" {
|
||||
if t, err := time.Parse(time.RFC3339, endStr); err == nil {
|
||||
filter.EndTime = &t
|
||||
}
|
||||
}
|
||||
if codeStr := query.Get("status_code"); codeStr != "" {
|
||||
if code, err := strconv.Atoi(codeStr); err == nil {
|
||||
filter.StatusCode = &code
|
||||
}
|
||||
}
|
||||
if page, err := strconv.Atoi(query.Get("page")); err == nil && page > 0 {
|
||||
filter.Page = page
|
||||
} else {
|
||||
filter.Page = 1
|
||||
}
|
||||
if pageSize, err := strconv.Atoi(query.Get("page_size")); err == nil && pageSize > 0 && pageSize <= 100 {
|
||||
filter.PageSize = pageSize
|
||||
} else {
|
||||
filter.PageSize = 20
|
||||
}
|
||||
|
||||
logs, total, err := h.service.QueryLogs(r.Context(), filter)
|
||||
if err != nil {
|
||||
response.Error(w, errors.Wrap(err, errors.ErrInternal))
|
||||
return
|
||||
}
|
||||
|
||||
response.PaginatedResponse(w, logs, total, filter.Page, filter.PageSize)
|
||||
}
|
||||
|
||||
// ExportLogs 导出日志为 CSV
|
||||
func (h *LogHandler) ExportLogs(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
|
||||
filter := model.LogQueryFilter{
|
||||
Service: query.Get("service"),
|
||||
Path: query.Get("path"),
|
||||
UserID: query.Get("user_id"),
|
||||
SupplierID: query.Get("supplier_id"),
|
||||
}
|
||||
|
||||
if startStr := query.Get("start"); startStr != "" {
|
||||
if t, err := time.Parse(time.RFC3339, startStr); err == nil {
|
||||
filter.StartTime = &t
|
||||
}
|
||||
}
|
||||
if endStr := query.Get("end"); endStr != "" {
|
||||
if t, err := time.Parse(time.RFC3339, endStr); err == nil {
|
||||
filter.EndTime = &t
|
||||
}
|
||||
}
|
||||
if codeStr := query.Get("status_code"); codeStr != "" {
|
||||
if code, err := strconv.Atoi(codeStr); err == nil {
|
||||
filter.StatusCode = &code
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=logs_"+time.Now().Format("20060102_150405")+".csv")
|
||||
|
||||
if err := h.service.ExportLogsCSV(r.Context(), filter, w); err != nil {
|
||||
response.Error(w, errors.Wrap(err, errors.ErrInternal))
|
||||
return
|
||||
}
|
||||
}
|
||||
86
internal/handler/metric_handler.go
Normal file
86
internal/handler/metric_handler.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/company/ai-ops/internal/domain/model"
|
||||
"github.com/company/ai-ops/internal/service"
|
||||
"github.com/company/ai-ops/pkg/errors"
|
||||
"github.com/company/ai-ops/pkg/response"
|
||||
)
|
||||
|
||||
// MetricHandler 是指标 HTTP 处理器
|
||||
type MetricHandler struct {
|
||||
service *service.MetricService
|
||||
}
|
||||
|
||||
func NewMetricHandler(s *service.MetricService) *MetricHandler {
|
||||
return &MetricHandler{service: s}
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册指标相关路由
|
||||
func (h *MetricHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/v1/ai-ops/metrics/realtime", h.GetRealtime)
|
||||
mux.HandleFunc("GET /api/v1/ai-ops/metrics/suppliers/count", h.GetSupplierCount)
|
||||
mux.HandleFunc("GET /api/v1/ai-ops/alerts/open/count", h.GetOpenAlertCount)
|
||||
mux.HandleFunc("GET /api/v1/ai-ops/metrics/query", h.QueryMetrics)
|
||||
}
|
||||
|
||||
// GetRealtime 返回实时指标
|
||||
func (h *MetricHandler) GetRealtime(w http.ResponseWriter, r *http.Request) {
|
||||
metrics, err := h.service.GetRealtimeMetrics(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, errors.Wrap(err, errors.ErrInternal))
|
||||
return
|
||||
}
|
||||
response.Success(w, metrics)
|
||||
}
|
||||
|
||||
// GetSupplierCount 返回活跃供应商数量
|
||||
func (h *MetricHandler) GetSupplierCount(w http.ResponseWriter, r *http.Request) {
|
||||
count, err := h.service.GetSupplierCount(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, errors.Wrap(err, errors.ErrInternal))
|
||||
return
|
||||
}
|
||||
response.Success(w, count)
|
||||
}
|
||||
|
||||
// GetOpenAlertCount 返回未关闭告警数量
|
||||
func (h *MetricHandler) GetOpenAlertCount(w http.ResponseWriter, r *http.Request) {
|
||||
count, err := h.service.GetOpenAlertCount(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, errors.Wrap(err, errors.ErrInternal))
|
||||
return
|
||||
}
|
||||
response.Success(w, count)
|
||||
}
|
||||
|
||||
// QueryMetrics 指标下钻查询
|
||||
func (h *MetricHandler) QueryMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
|
||||
req := model.MetricQueryRequest{
|
||||
Source: query.Get("source"),
|
||||
Name: query.Get("name"),
|
||||
}
|
||||
|
||||
if startStr := query.Get("start"); startStr != "" {
|
||||
if t, err := time.Parse(time.RFC3339, startStr); err == nil {
|
||||
req.StartTime = t
|
||||
}
|
||||
}
|
||||
if endStr := query.Get("end"); endStr != "" {
|
||||
if t, err := time.Parse(time.RFC3339, endStr); err == nil {
|
||||
req.EndTime = t
|
||||
}
|
||||
}
|
||||
|
||||
points, err := h.service.QueryMetrics(r.Context(), req)
|
||||
if err != nil {
|
||||
response.Error(w, errors.Wrap(err, errors.ErrInternal))
|
||||
return
|
||||
}
|
||||
response.Success(w, points)
|
||||
}
|
||||
93
internal/handler/metric_handler_test.go
Normal file
93
internal/handler/metric_handler_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/company/ai-ops/internal/domain/model"
|
||||
"github.com/company/ai-ops/internal/service"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type mockMetricRepo struct{ mock.Mock }
|
||||
|
||||
func (m *mockMetricRepo) GetRealtime(ctx context.Context) (*model.RealtimeMetrics, error) {
|
||||
args := m.Called(ctx)
|
||||
return args.Get(0).(*model.RealtimeMetrics), args.Error(1)
|
||||
}
|
||||
func (m *mockMetricRepo) Query(ctx context.Context, req model.MetricQueryRequest) ([]model.MetricPoint, error) {
|
||||
args := m.Called(ctx, req)
|
||||
return args.Get(0).([]model.MetricPoint), args.Error(1)
|
||||
}
|
||||
func (m *mockMetricRepo) GetLatest(ctx context.Context, source, name string) (*model.MetricPoint, error) {
|
||||
args := m.Called(ctx, source, name)
|
||||
return args.Get(0).(*model.MetricPoint), args.Error(1)
|
||||
}
|
||||
|
||||
type mockAlertRepo struct{ mock.Mock }
|
||||
|
||||
func (m *mockAlertRepo) GetOpenCount(ctx context.Context) (*model.AlertCount, error) {
|
||||
args := m.Called(ctx)
|
||||
return args.Get(0).(*model.AlertCount), args.Error(1)
|
||||
}
|
||||
func (m *mockAlertRepo) ListRules(ctx context.Context) ([]model.AlertRule, error) {
|
||||
args := m.Called(ctx)
|
||||
return args.Get(0).([]model.AlertRule), args.Error(1)
|
||||
}
|
||||
func (m *mockAlertRepo) GetRuleByID(ctx context.Context, id string) (*model.AlertRule, error) {
|
||||
args := m.Called(ctx, id)
|
||||
return args.Get(0).(*model.AlertRule), args.Error(1)
|
||||
}
|
||||
func (m *mockAlertRepo) CreateRule(ctx context.Context, rule *model.AlertRule) error {
|
||||
args := m.Called(ctx, rule)
|
||||
return args.Error(0)
|
||||
}
|
||||
func (m *mockAlertRepo) UpdateRule(ctx context.Context, rule *model.AlertRule) error {
|
||||
args := m.Called(ctx, rule)
|
||||
return args.Error(0)
|
||||
}
|
||||
func (m *mockAlertRepo) DeleteRule(ctx context.Context, id string) error {
|
||||
args := m.Called(ctx, id)
|
||||
return args.Error(0)
|
||||
}
|
||||
func (m *mockAlertRepo) ListEvents(ctx context.Context, status string, page, pageSize int) ([]model.AlertEvent, int, error) {
|
||||
args := m.Called(ctx, status, page, pageSize)
|
||||
return args.Get(0).([]model.AlertEvent), args.Int(1), args.Error(2)
|
||||
}
|
||||
func (m *mockAlertRepo) CreateEvent(ctx context.Context, event *model.AlertEvent) error {
|
||||
args := m.Called(ctx, event)
|
||||
return args.Error(0)
|
||||
}
|
||||
func (m *mockAlertRepo) CreateEventWithAggregation(ctx context.Context, event *model.AlertEvent, window time.Duration, threshold int) (*model.AlertEvent, error) {
|
||||
args := m.Called(ctx, event, window, threshold)
|
||||
return args.Get(0).(*model.AlertEvent), args.Error(1)
|
||||
}
|
||||
func (m *mockAlertRepo) UpdateEventStatus(ctx context.Context, id, status string) error {
|
||||
args := m.Called(ctx, id, status)
|
||||
return args.Error(0)
|
||||
}
|
||||
func (m *mockAlertRepo) EscalateEvent(ctx context.Context, id, newLevel string) error {
|
||||
args := m.Called(ctx, id, newLevel)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func TestMetricHandler_GetRealtime(t *testing.T) {
|
||||
mr := new(mockMetricRepo)
|
||||
ar := new(mockAlertRepo)
|
||||
svc := service.NewMetricService(mr, ar)
|
||||
h := NewMetricHandler(svc)
|
||||
|
||||
expected := &model.RealtimeMetrics{QPS: 100, AvgLatency: 50, P99Latency: 100, ErrorRate: 0.01}
|
||||
mr.On("GetRealtime", mock.Anything).Return(expected, nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/ai-ops/metrics/realtime", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetRealtime(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), `"qps":100`)
|
||||
}
|
||||
84
internal/handler/rule_handler.go
Normal file
84
internal/handler/rule_handler.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/company/ai-ops/internal/domain/model"
|
||||
"github.com/company/ai-ops/internal/service"
|
||||
"github.com/company/ai-ops/pkg/errors"
|
||||
"github.com/company/ai-ops/pkg/response"
|
||||
)
|
||||
|
||||
// RuleHandler 是告警规则 HTTP 处理器
|
||||
type RuleHandler struct {
|
||||
service *service.RuleService
|
||||
}
|
||||
|
||||
func NewRuleHandler(s *service.RuleService) *RuleHandler {
|
||||
return &RuleHandler{service: s}
|
||||
}
|
||||
|
||||
func (h *RuleHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/v1/ai-ops/rules", h.ListRules)
|
||||
mux.HandleFunc("GET /api/v1/ai-ops/rules/{id}", h.GetRule)
|
||||
mux.HandleFunc("POST /api/v1/ai-ops/rules", h.CreateRule)
|
||||
mux.HandleFunc("PUT /api/v1/ai-ops/rules/{id}", h.UpdateRule)
|
||||
mux.HandleFunc("DELETE /api/v1/ai-ops/rules/{id}", h.DeleteRule)
|
||||
}
|
||||
|
||||
func (h *RuleHandler) ListRules(w http.ResponseWriter, r *http.Request) {
|
||||
rules, err := h.service.ListRules(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, errors.Wrap(err, errors.ErrInternal))
|
||||
return
|
||||
}
|
||||
response.Success(w, rules)
|
||||
}
|
||||
|
||||
func (h *RuleHandler) GetRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
rule, err := h.service.GetRule(r.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(w, errors.Wrap(err, errors.ErrNotFound))
|
||||
return
|
||||
}
|
||||
response.Success(w, rule)
|
||||
}
|
||||
|
||||
func (h *RuleHandler) CreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
var rule model.AlertRule
|
||||
if err := decodeJSON(r, &rule); err != nil {
|
||||
response.Error(w, errors.ErrBadRequest.WithDetail(map[string]any{"error": err.Error()}))
|
||||
return
|
||||
}
|
||||
if err := h.service.CreateRule(r.Context(), &rule); err != nil {
|
||||
response.Error(w, errors.Wrap(err, errors.ErrBadRequest))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
response.Success(w, rule)
|
||||
}
|
||||
|
||||
func (h *RuleHandler) UpdateRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var rule model.AlertRule
|
||||
if err := decodeJSON(r, &rule); err != nil {
|
||||
response.Error(w, errors.ErrBadRequest.WithDetail(map[string]any{"error": err.Error()}))
|
||||
return
|
||||
}
|
||||
rule.ID = id
|
||||
if err := h.service.UpdateRule(r.Context(), &rule); err != nil {
|
||||
response.Error(w, errors.Wrap(err, errors.ErrBadRequest))
|
||||
return
|
||||
}
|
||||
response.Success(w, rule)
|
||||
}
|
||||
|
||||
func (h *RuleHandler) DeleteRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := h.service.DeleteRule(r.Context(), id); err != nil {
|
||||
response.Error(w, errors.Wrap(err, errors.ErrInternal))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
10
internal/handler/utils.go
Normal file
10
internal/handler/utils.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func decodeJSON(r *http.Request, v any) error {
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
Reference in New Issue
Block a user