mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 19:57:09 +00:00
Build canonical Patrol attention workbench
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/recovery"
|
||||
recoverymanager "github.com/rcourtman/pulse-go-rewrite/internal/recovery/manager"
|
||||
recoverymodel "github.com/rcourtman/pulse-go-rewrite/internal/recovery/model"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
attentionHistoryReadLimit = 500
|
||||
attentionPostureBatchSize = 200
|
||||
)
|
||||
|
||||
type attentionAlertSnapshot func(context.Context) ([]alerts.Alert, []alerts.Alert, error)
|
||||
|
||||
type AttentionHandlers struct {
|
||||
readAlerts attentionAlertSnapshot
|
||||
recoveryManager *recoverymanager.Manager
|
||||
}
|
||||
|
||||
func NewAttentionHandlers(
|
||||
getMonitor func(context.Context) *monitoring.Monitor,
|
||||
recoveryManager *recoverymanager.Manager,
|
||||
) *AttentionHandlers {
|
||||
return &AttentionHandlers{
|
||||
readAlerts: func(ctx context.Context) ([]alerts.Alert, []alerts.Alert, error) {
|
||||
if getMonitor == nil {
|
||||
return nil, nil, fmt.Errorf("monitor is not configured")
|
||||
}
|
||||
monitor := getMonitor(ctx)
|
||||
if monitor == nil || monitor.GetAlertManager() == nil {
|
||||
return nil, nil, fmt.Errorf("alert lifecycle is not available")
|
||||
}
|
||||
manager := monitor.GetAlertManager()
|
||||
return manager.GetActiveAlerts(), manager.GetAlertHistory(attentionHistoryReadLimit), nil
|
||||
},
|
||||
recoveryManager: recoveryManager,
|
||||
}
|
||||
}
|
||||
|
||||
type attentionListResponse struct {
|
||||
Data []ai.AttentionItem `json:"data"`
|
||||
Summary ai.AttentionSummary `json:"summary"`
|
||||
Meta struct {
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
Total int `json:"total"`
|
||||
TotalPages int `json:"totalPages"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
|
||||
func (h *AttentionHandlers) HandleAttention(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/ai/patrol/attention")
|
||||
switch {
|
||||
case path == "" || path == "/":
|
||||
h.handleAttentionList(w, r)
|
||||
case path == "/summary":
|
||||
h.handleAttentionSummary(w, r)
|
||||
case strings.HasPrefix(path, "/"):
|
||||
h.handleAttentionDetail(w, r, strings.TrimPrefix(path, "/"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AttentionHandlers) handleAttentionList(w http.ResponseWriter, r *http.Request) {
|
||||
filter, page, limit, ok := parseAttentionListQuery(w, r.URL.Query())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
projection, err := h.project(r.Context(), true)
|
||||
if err != nil {
|
||||
writeAttentionUnavailable(w, err)
|
||||
return
|
||||
}
|
||||
filtered, err := ai.FilterAttentionDetails(projection.Details, filter)
|
||||
if err != nil {
|
||||
writeErrorResponse(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
"invalid_attention_filter",
|
||||
err.Error(),
|
||||
map[string]string{"filter": string(filter)},
|
||||
)
|
||||
return
|
||||
}
|
||||
paged, err := ai.PaginateAttentionDetails(filtered, page, limit)
|
||||
if err != nil {
|
||||
writeErrorResponse(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
"invalid_attention_page",
|
||||
err.Error(),
|
||||
nil,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
response := attentionListResponse{
|
||||
Data: make([]ai.AttentionItem, 0, len(paged)),
|
||||
Summary: projection.Summary,
|
||||
}
|
||||
for _, detail := range paged {
|
||||
response.Data = append(response.Data, detail.Item)
|
||||
}
|
||||
response.Meta.Page = page
|
||||
response.Meta.Limit = limit
|
||||
response.Meta.Total = len(filtered)
|
||||
if len(filtered) > 0 {
|
||||
response.Meta.TotalPages = (len(filtered) + limit - 1) / limit
|
||||
}
|
||||
if err := utils.WriteJSONResponse(w, response); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize Patrol attention list")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AttentionHandlers) handleAttentionSummary(w http.ResponseWriter, r *http.Request) {
|
||||
projection, err := h.project(r.Context(), false)
|
||||
if err != nil {
|
||||
writeAttentionUnavailable(w, err)
|
||||
return
|
||||
}
|
||||
if err := utils.WriteJSONResponse(w, projection.Summary); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize Patrol attention summary")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AttentionHandlers) handleAttentionDetail(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
rawID string,
|
||||
) {
|
||||
itemID, err := url.PathUnescape(rawID)
|
||||
if err != nil || strings.TrimSpace(itemID) == "" {
|
||||
writeErrorResponse(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
"invalid_attention_id",
|
||||
"Attention item ID is invalid.",
|
||||
nil,
|
||||
)
|
||||
return
|
||||
}
|
||||
projection, err := h.project(r.Context(), true)
|
||||
if err != nil {
|
||||
writeAttentionUnavailable(w, err)
|
||||
return
|
||||
}
|
||||
for _, detail := range projection.Details {
|
||||
if detail.Item.ID == itemID {
|
||||
if err := utils.WriteJSONResponse(w, detail); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize Patrol attention detail")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
writeErrorResponse(
|
||||
w,
|
||||
http.StatusNotFound,
|
||||
"attention_item_not_found",
|
||||
"Attention item was not found.",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func (h *AttentionHandlers) project(
|
||||
ctx context.Context,
|
||||
includeProtectionPosture bool,
|
||||
) (ai.AttentionProjection, error) {
|
||||
if h == nil || h.readAlerts == nil {
|
||||
return ai.AttentionProjection{}, fmt.Errorf("attention lifecycle source is not configured")
|
||||
}
|
||||
active, history, err := h.readAlerts(ctx)
|
||||
if err != nil {
|
||||
return ai.AttentionProjection{}, err
|
||||
}
|
||||
postures := map[string]recoverymodel.ProtectionPosture{}
|
||||
postureComplete := true
|
||||
if includeProtectionPosture {
|
||||
resourceIDs := attentionResourceIDs(active, history)
|
||||
postures, postureComplete = h.loadProtectionPostures(
|
||||
ctx,
|
||||
GetOrgID(ctx),
|
||||
resourceIDs,
|
||||
)
|
||||
}
|
||||
projection := ai.ProjectAttentionItems(active, history, postures, time.Now().UTC())
|
||||
if !postureComplete {
|
||||
projection.Summary.CoverageState = "partial"
|
||||
}
|
||||
ai.GetPatrolMetrics().ObserveAttentionProjection(projection, projection.Summary.EvaluatedAt)
|
||||
return projection, nil
|
||||
}
|
||||
|
||||
func (h *AttentionHandlers) loadProtectionPostures(
|
||||
ctx context.Context,
|
||||
orgID string,
|
||||
resourceIDs []string,
|
||||
) (map[string]recoverymodel.ProtectionPosture, bool) {
|
||||
result := make(map[string]recoverymodel.ProtectionPosture, len(resourceIDs))
|
||||
if len(resourceIDs) == 0 {
|
||||
return result, true
|
||||
}
|
||||
if h == nil || h.recoveryManager == nil {
|
||||
return result, false
|
||||
}
|
||||
store, err := h.recoveryManager.StoreForOrg(orgID)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Patrol attention protection posture is unavailable")
|
||||
return result, false
|
||||
}
|
||||
|
||||
complete := true
|
||||
for start := 0; start < len(resourceIDs); start += attentionPostureBatchSize {
|
||||
end := start + attentionPostureBatchSize
|
||||
if end > len(resourceIDs) {
|
||||
end = len(resourceIDs)
|
||||
}
|
||||
batch := resourceIDs[start:end]
|
||||
postures, _, listErr := store.ListProtectionPostures(ctx, recovery.ProtectionPostureQuery{
|
||||
SubjectResourceIDs: batch,
|
||||
Page: 1,
|
||||
Limit: len(batch),
|
||||
})
|
||||
if listErr != nil {
|
||||
log.Debug().Err(listErr).Msg("Patrol attention posture batch is unavailable")
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
for _, posture := range postures {
|
||||
result[posture.SubjectResourceID] = posture
|
||||
}
|
||||
}
|
||||
return result, complete
|
||||
}
|
||||
|
||||
func parseAttentionListQuery(
|
||||
w http.ResponseWriter,
|
||||
query url.Values,
|
||||
) (ai.AttentionFilter, int, int, bool) {
|
||||
filter := ai.AttentionFilter(strings.TrimSpace(query.Get("filter")))
|
||||
if filter == "" {
|
||||
filter = ai.AttentionFilterActive
|
||||
}
|
||||
if !filter.Valid() {
|
||||
writeErrorResponse(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
"invalid_attention_filter",
|
||||
"Filter must be active, open, acknowledged, suppressed, stale_unknown, resolved, or all.",
|
||||
map[string]string{"filter": string(filter)},
|
||||
)
|
||||
return "", 0, 0, false
|
||||
}
|
||||
page, valid := parsePositiveAttentionInt(w, query.Get("page"), 1, "page")
|
||||
if !valid {
|
||||
return "", 0, 0, false
|
||||
}
|
||||
limit, valid := parsePositiveAttentionInt(
|
||||
w,
|
||||
query.Get("limit"),
|
||||
ai.DefaultAttentionPageSize,
|
||||
"limit",
|
||||
)
|
||||
if !valid {
|
||||
return "", 0, 0, false
|
||||
}
|
||||
if limit > ai.MaxAttentionPageSize {
|
||||
writeErrorResponse(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
"attention_limit_exceeded",
|
||||
fmt.Sprintf("Attention limit must not exceed %d.", ai.MaxAttentionPageSize),
|
||||
map[string]string{"limit": strconv.Itoa(ai.MaxAttentionPageSize)},
|
||||
)
|
||||
return "", 0, 0, false
|
||||
}
|
||||
return filter, page, limit, true
|
||||
}
|
||||
|
||||
func parsePositiveAttentionInt(
|
||||
w http.ResponseWriter,
|
||||
raw string,
|
||||
fallback int,
|
||||
field string,
|
||||
) (int, bool) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return fallback, true
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 1 {
|
||||
writeErrorResponse(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
"invalid_attention_"+field,
|
||||
"Attention "+field+" must be a positive integer.",
|
||||
map[string]string{field: raw},
|
||||
)
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func attentionResourceIDs(active, history []alerts.Alert) []string {
|
||||
unique := make(map[string]struct{}, len(active)+len(history))
|
||||
add := func(values []alerts.Alert, history bool) {
|
||||
for _, alert := range values {
|
||||
if alert.OperationalRecord == nil {
|
||||
continue
|
||||
}
|
||||
if history && alert.OperationalRecord.State != operationaltrust.OperationalResolved {
|
||||
continue
|
||||
}
|
||||
if resourceID := strings.TrimSpace(alert.OperationalRecord.SubjectResourceID); resourceID != "" {
|
||||
unique[resourceID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
add(active, false)
|
||||
add(history, true)
|
||||
result := make([]string, 0, len(unique))
|
||||
for resourceID := range unique {
|
||||
result = append(result, resourceID)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func writeAttentionUnavailable(w http.ResponseWriter, err error) {
|
||||
log.Warn().Err(err).Msg("Patrol attention read model is unavailable")
|
||||
writeErrorResponse(
|
||||
w,
|
||||
http.StatusServiceUnavailable,
|
||||
"attention_read_model_unavailable",
|
||||
"Patrol attention is temporarily unavailable. No calm or healthy state has been inferred.",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
|
||||
)
|
||||
|
||||
func TestAttentionHandlersListUsesCanonicalCountAndFilters(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 6, 0, 0, 0, time.UTC)
|
||||
handler := &AttentionHandlers{
|
||||
readAlerts: func(context.Context) ([]alerts.Alert, []alerts.Alert, error) {
|
||||
return []alerts.Alert{
|
||||
attentionHandlerAlert("open", operationaltrust.OperationalOpen, now),
|
||||
attentionHandlerAlert("ack", operationaltrust.OperationalAcknowledged, now),
|
||||
attentionHandlerAlert("stale", operationaltrust.OperationalStale, now),
|
||||
}, nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/ai/patrol/attention?filter=acknowledged&limit=10",
|
||||
nil,
|
||||
)
|
||||
response := httptest.NewRecorder()
|
||||
handler.HandleAttention(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
var payload attentionListResponse
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(payload.Data) != 1 || payload.Data[0].State != operationaltrust.OperationalAcknowledged {
|
||||
t.Fatalf("data = %+v, want acknowledged item", payload.Data)
|
||||
}
|
||||
if payload.Summary.ActiveCount != 2 ||
|
||||
payload.Summary.AcknowledgedCount != 1 ||
|
||||
payload.Summary.UncertainCount != 1 {
|
||||
t.Fatalf("summary = %+v", payload.Summary)
|
||||
}
|
||||
if payload.Summary.CoverageState != "partial" {
|
||||
t.Fatalf("coverage = %q, want partial without posture source", payload.Summary.CoverageState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttentionHandlersSummaryAndDetailShareOneProjection(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 7, 0, 0, 0, time.UTC)
|
||||
handler := &AttentionHandlers{
|
||||
readAlerts: func(context.Context) ([]alerts.Alert, []alerts.Alert, error) {
|
||||
return []alerts.Alert{
|
||||
attentionHandlerAlert("record-1", operationaltrust.OperationalOpen, now),
|
||||
}, nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
summaryRequest := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/ai/patrol/attention/summary",
|
||||
nil,
|
||||
)
|
||||
summaryResponse := httptest.NewRecorder()
|
||||
handler.HandleAttention(summaryResponse, summaryRequest)
|
||||
if summaryResponse.Code != http.StatusOK {
|
||||
t.Fatalf("summary status = %d body=%s", summaryResponse.Code, summaryResponse.Body.String())
|
||||
}
|
||||
var summary ai.AttentionSummary
|
||||
if err := json.Unmarshal(summaryResponse.Body.Bytes(), &summary); err != nil {
|
||||
t.Fatalf("decode summary: %v", err)
|
||||
}
|
||||
if summary.ActiveCount != 1 {
|
||||
t.Fatalf("ActiveCount = %d, want 1", summary.ActiveCount)
|
||||
}
|
||||
|
||||
detailRequest := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/ai/patrol/attention/record-1",
|
||||
nil,
|
||||
)
|
||||
detailResponse := httptest.NewRecorder()
|
||||
handler.HandleAttention(detailResponse, detailRequest)
|
||||
if detailResponse.Code != http.StatusOK {
|
||||
t.Fatalf("detail status = %d body=%s", detailResponse.Code, detailResponse.Body.String())
|
||||
}
|
||||
var detail ai.AttentionItemDetail
|
||||
if err := json.Unmarshal(detailResponse.Body.Bytes(), &detail); err != nil {
|
||||
t.Fatalf("decode detail: %v", err)
|
||||
}
|
||||
if detail.Item.ID != "record-1" ||
|
||||
detail.OperationalRecord.ID != detail.Item.OperationalRecordID ||
|
||||
len(detail.Evidence) != 1 {
|
||||
t.Fatalf("detail = %+v", detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttentionHandlersDetailSupportsCanonicalIDsContainingSlashes(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 7, 30, 0, 0, time.UTC)
|
||||
const recordID = "agent:node-1/disk:mnt-disk2::metric-threshold:disk"
|
||||
handler := &AttentionHandlers{
|
||||
readAlerts: func(context.Context) ([]alerts.Alert, []alerts.Alert, error) {
|
||||
return []alerts.Alert{
|
||||
attentionHandlerAlert(recordID, operationaltrust.OperationalOpen, now),
|
||||
}, nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/ai/patrol/attention/agent%3Anode-1%2Fdisk%3Amnt-disk2%3A%3Ametric-threshold%3Adisk",
|
||||
nil,
|
||||
)
|
||||
response := httptest.NewRecorder()
|
||||
handler.HandleAttention(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
var detail ai.AttentionItemDetail
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &detail); err != nil {
|
||||
t.Fatalf("decode detail: %v", err)
|
||||
}
|
||||
if detail.Item.ID != recordID {
|
||||
t.Fatalf("detail ID = %q, want %q", detail.Item.ID, recordID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttentionHandlersFailClosedWhenLifecycleUnavailable(t *testing.T) {
|
||||
handler := &AttentionHandlers{
|
||||
readAlerts: func(context.Context) ([]alerts.Alert, []alerts.Alert, error) {
|
||||
return nil, nil, errors.New("collector state unavailable")
|
||||
},
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/ai/patrol/attention/summary",
|
||||
nil,
|
||||
)
|
||||
response := httptest.NewRecorder()
|
||||
handler.HandleAttention(response, request)
|
||||
if response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if body := response.Body.String(); body == "" ||
|
||||
!json.Valid(response.Body.Bytes()) {
|
||||
t.Fatalf("expected typed unavailable response, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttentionHandlersRejectInvalidOrUnboundedQueries(t *testing.T) {
|
||||
handler := &AttentionHandlers{
|
||||
readAlerts: func(context.Context) ([]alerts.Alert, []alerts.Alert, error) {
|
||||
return nil, nil, nil
|
||||
},
|
||||
}
|
||||
for _, path := range []string{
|
||||
"/api/ai/patrol/attention?filter=healthy",
|
||||
"/api/ai/patrol/attention?page=0",
|
||||
"/api/ai/patrol/attention?limit=201",
|
||||
"/api/ai/patrol/attention/%20",
|
||||
} {
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
response := httptest.NewRecorder()
|
||||
handler.HandleAttention(response, request)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s status = %d body=%s", path, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func attentionHandlerAlert(
|
||||
id string,
|
||||
state operationaltrust.OperationalState,
|
||||
now time.Time,
|
||||
) alerts.Alert {
|
||||
resourceID := "resource-" + id
|
||||
validUntil := now.Add(time.Hour)
|
||||
evidence := operationaltrust.EvidenceEnvelope{
|
||||
ID: "evidence-" + id,
|
||||
Source: operationaltrust.EvidenceSource{
|
||||
Provider: "test",
|
||||
Collector: "test",
|
||||
},
|
||||
Subject: operationaltrust.EvidenceSubject{ResourceID: resourceID},
|
||||
ObservedAt: now,
|
||||
IngestedAt: now,
|
||||
ValidUntil: &validUntil,
|
||||
Completeness: operationaltrust.EvidenceComplete,
|
||||
Confidence: operationaltrust.EvidenceConfirmed,
|
||||
Permissions: operationaltrust.EvidencePermissionsSufficient,
|
||||
}
|
||||
record := operationaltrust.OperationalRecord{
|
||||
ID: id,
|
||||
CanonicalSpecID: "spec-" + id,
|
||||
SubjectResourceID: resourceID,
|
||||
State: state,
|
||||
Severity: operationaltrust.SeverityWarning,
|
||||
FirstObservedAt: now.Add(-time.Hour),
|
||||
LastObservedAt: now,
|
||||
StateChangedAt: now,
|
||||
EvidenceIDs: []string{evidence.ID},
|
||||
CauseKey: "cause-" + id,
|
||||
ImpactSummary: "Service interruption is possible.",
|
||||
RelatedResourceIDs: []string{},
|
||||
}
|
||||
switch state {
|
||||
case operationaltrust.OperationalAcknowledged:
|
||||
record.Acknowledgement = &operationaltrust.Acknowledgement{At: now, By: "operator"}
|
||||
case operationaltrust.OperationalSuppressed:
|
||||
record.Suppression = &operationaltrust.Suppression{
|
||||
At: now,
|
||||
By: "operator",
|
||||
Reason: "maintenance",
|
||||
}
|
||||
case operationaltrust.OperationalResolved:
|
||||
record.ResolvedAt = &now
|
||||
}
|
||||
return alerts.Alert{
|
||||
ID: "alert-" + id,
|
||||
Type: "service-health",
|
||||
Level: alerts.AlertLevelWarning,
|
||||
ResourceID: resourceID,
|
||||
ResourceName: "Resource " + id,
|
||||
Message: "Service health needs attention.",
|
||||
StartTime: now.Add(-time.Hour),
|
||||
LastSeen: now,
|
||||
OperationalRecord: &record,
|
||||
Evidence: []operationaltrust.EvidenceEnvelope{evidence},
|
||||
}
|
||||
}
|
||||
@@ -621,6 +621,8 @@ var allRouteAllowlist = []string{
|
||||
"/api/ai/patrol/status",
|
||||
"/api/ai/patrol/stream",
|
||||
"/api/ai/patrol/findings",
|
||||
"/api/ai/patrol/attention",
|
||||
"/api/ai/patrol/attention/",
|
||||
"/api/ai/patrol/history",
|
||||
"/api/ai/patrol/run",
|
||||
"/api/ai/patrol/preflight",
|
||||
|
||||
@@ -106,6 +106,7 @@ type Router struct {
|
||||
unifiedAgentHandlers *UnifiedAgentHandlers
|
||||
systemSettingsHandler *SystemSettingsHandler
|
||||
aiSettingsHandler *AISettingsHandler
|
||||
attentionHandlers *AttentionHandlers
|
||||
aiHandler *AIHandler // AI chat handler
|
||||
discoveryHandlers *DiscoveryHandlers
|
||||
resourceHandlers *ResourceHandlers
|
||||
@@ -464,6 +465,7 @@ func (r *Router) setupRoutes() {
|
||||
)
|
||||
recoveryManager := recoverymanager.New(r.multiTenant)
|
||||
r.recoveryHandlers = NewRecoveryHandlers(recoveryManager)
|
||||
r.attentionHandlers = NewAttentionHandlers(r.configHandlers.getMonitor, recoveryManager)
|
||||
if r.mtMonitor != nil {
|
||||
r.mtMonitor.SetRecoveryManager(recoveryManager)
|
||||
}
|
||||
|
||||
@@ -112,6 +112,8 @@ func (r *Router) registerAIRelayRoutesGroup() {
|
||||
r.mux.HandleFunc("/api/ai/patrol/status", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetPatrolStatus)))
|
||||
r.mux.HandleFunc("/api/ai/patrol/stream", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandlePatrolStream)))
|
||||
r.mux.HandleFunc("/api/ai/patrol/findings", RequireAuth(r.config, r.routeAIPatrolFindings))
|
||||
r.mux.HandleFunc("/api/ai/patrol/attention", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.attentionHandlers.HandleAttention)))
|
||||
r.mux.HandleFunc("/api/ai/patrol/attention/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.attentionHandlers.HandleAttention)))
|
||||
// SECURITY: AI Patrol read endpoints - require ai:execute scope
|
||||
r.mux.HandleFunc("/api/ai/patrol/history", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetFindingsHistory)))
|
||||
r.mux.HandleFunc("/api/ai/patrol/run", RequireAdmin(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleForcePatrol)))
|
||||
|
||||
Reference in New Issue
Block a user