e74cb454c8
Turn the rule_run_actions ledger into operator intelligence. A new ActivityService rolls the ledger up by time window (24h / 7d / all-time) into syncs, operations and removals with success/failure counts and the number of distinct objects affected, plus all-time totals per action type and a most-active-rules ranking. A companion filtered, paginated feed answers the operational questions directly: what action ran, what happened, to which object, triggered by whom, and when. Backend: - ActivityService.Summary() and ListActions(filter) over the joined rule_run_actions / rule_runs / rules tables, categorising each action type as Sync (AddToGroup/AddGroupToGroup), Removal (RemoveFromGroupIfNoLongerMatched) or Operation (everything else). - GET /api/v1/activity/summary and GET /api/v1/activity (category, actionType, status, ruleId, search, pagination). - Table-driven test seeding a run with mixed action types/statuses and asserting window roll-ups, distinct-object counts, top rules, and the category/status feed filters. Frontend: - New Activity page: window summary cards, by-action-type and most-active-rules tables, and a drill-in feed with category/result/ object filters and a detail dialog that pretty-prints the action's details JSON and error. - Wired into the vertical sidebar and horizontal navbar under Automation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
351 lines
10 KiB
Go
351 lines
10 KiB
Go
// Package services - activity intelligence aggregation service.
|
|
//
|
|
// The activity service turns the raw rule_run_actions ledger into operator-
|
|
// friendly intelligence: how many syncs, operations and removals happened, to
|
|
// how many objects, and a drill-in feed answering what action ran, what
|
|
// happened, to which object, triggered by whom, and when.
|
|
package services
|
|
|
|
import (
|
|
"database/sql"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
|
|
)
|
|
|
|
// Activity categories group the individual AD action types into the three
|
|
// buckets operators reason about.
|
|
const (
|
|
CategorySync = "Sync" // membership additions (AddToGroup / AddGroupToGroup)
|
|
CategoryOperation = "Operation" // structural changes (MoveToOu / EnsureGroupExists)
|
|
CategoryRemoval = "Removal" // membership removals (RemoveFromGroupIfNoLongerMatched)
|
|
)
|
|
|
|
// syncActionTypes / removalActionTypes drive both the SQL categorisation and
|
|
// the Go-side helpers so the two never drift apart.
|
|
var (
|
|
syncActionTypes = []string{"AddToGroup", "AddGroupToGroup"}
|
|
removalActionTypes = []string{"RemoveFromGroupIfNoLongerMatched"}
|
|
)
|
|
|
|
// categorize maps an action type to its activity category.
|
|
func categorize(actionType string) string {
|
|
for _, a := range syncActionTypes {
|
|
if a == actionType {
|
|
return CategorySync
|
|
}
|
|
}
|
|
for _, a := range removalActionTypes {
|
|
if a == actionType {
|
|
return CategoryRemoval
|
|
}
|
|
}
|
|
return CategoryOperation
|
|
}
|
|
|
|
// ActivityWindow summarises action activity over a time window.
|
|
type ActivityWindow struct {
|
|
Key string `json:"key"`
|
|
Label string `json:"label"`
|
|
Total int `json:"total"`
|
|
Success int `json:"success"`
|
|
Failed int `json:"failed"`
|
|
Syncs int `json:"syncs"`
|
|
Operations int `json:"operations"`
|
|
Removals int `json:"removals"`
|
|
ObjectsAffected int `json:"objectsAffected"`
|
|
}
|
|
|
|
// ActionTypeCount is an all-time roll-up for a single action type.
|
|
type ActionTypeCount struct {
|
|
ActionType string `json:"actionType"`
|
|
Category string `json:"category"`
|
|
Total int `json:"total"`
|
|
Success int `json:"success"`
|
|
Failed int `json:"failed"`
|
|
}
|
|
|
|
// RuleActivityCount ranks rules by how much activity they generated.
|
|
type RuleActivityCount struct {
|
|
RuleID string `json:"ruleId"`
|
|
RuleName string `json:"ruleName"`
|
|
Total int `json:"total"`
|
|
Failed int `json:"failed"`
|
|
}
|
|
|
|
// ActivitySummary is the composite response for the intelligence page header.
|
|
type ActivitySummary struct {
|
|
Windows []ActivityWindow `json:"windows"`
|
|
ByActionType []ActionTypeCount `json:"byActionType"`
|
|
TopRules []RuleActivityCount `json:"topRules"`
|
|
GeneratedUTC string `json:"generatedUtc"`
|
|
}
|
|
|
|
// ActivityRecord is one row of the drill-in feed.
|
|
type ActivityRecord struct {
|
|
ID string `json:"id"`
|
|
RuleRunID string `json:"ruleRunId"`
|
|
RuleID string `json:"ruleId"`
|
|
RuleName string `json:"ruleName"`
|
|
ObjectDN string `json:"objectDn"`
|
|
ActionType string `json:"actionType"`
|
|
Category string `json:"category"`
|
|
Status string `json:"status"`
|
|
DetailsJSON *string `json:"detailsJson,omitempty"`
|
|
ErrorMessage *string `json:"errorMessage,omitempty"`
|
|
DurationMS *int `json:"durationMs,omitempty"`
|
|
TriggeredBy *string `json:"triggeredBy,omitempty"`
|
|
CreatedUTC string `json:"createdUtc"`
|
|
}
|
|
|
|
// ActivityFilter narrows the drill-in feed.
|
|
type ActivityFilter struct {
|
|
Category string
|
|
ActionType string
|
|
Status string
|
|
RuleID string
|
|
Search string
|
|
Offset int
|
|
Limit int
|
|
}
|
|
|
|
// ActivityService aggregates read-only intelligence from the action ledger.
|
|
type ActivityService struct {
|
|
db *sql.DB
|
|
logger *logging.Logger
|
|
}
|
|
|
|
// NewActivityService creates a new ActivityService.
|
|
func NewActivityService(db *sql.DB, logger *logging.Logger) *ActivityService {
|
|
return &ActivityService{db: db, logger: logger}
|
|
}
|
|
|
|
// caseSum builds a "COALESCE(SUM(CASE WHEN <cond> THEN 1 ELSE 0 END),0)" fragment.
|
|
func caseSum(cond string) string {
|
|
return "COALESCE(SUM(CASE WHEN " + cond + " THEN 1 ELSE 0 END),0)"
|
|
}
|
|
|
|
// inList renders a quoted SQL IN(...) list from action-type constants. The
|
|
// values are compile-time constants, never user input, so inlining is safe.
|
|
func inList(values []string) string {
|
|
quoted := make([]string, len(values))
|
|
for i, v := range values {
|
|
quoted[i] = "'" + v + "'"
|
|
}
|
|
return "(" + strings.Join(quoted, ",") + ")"
|
|
}
|
|
|
|
// Summary returns the composite intelligence payload.
|
|
func (s *ActivityService) Summary() (*ActivitySummary, error) {
|
|
now := time.Now().UTC()
|
|
out := &ActivitySummary{GeneratedUTC: now.Format(time.RFC3339)}
|
|
|
|
windows := []struct {
|
|
key, label string
|
|
since *time.Time
|
|
}{
|
|
{"24h", "Last 24 hours", ptrTime(now.Add(-24 * time.Hour))},
|
|
{"7d", "Last 7 days", ptrTime(now.Add(-7 * 24 * time.Hour))},
|
|
{"all", "All time", nil},
|
|
}
|
|
for _, w := range windows {
|
|
win, err := s.loadWindow(w.key, w.label, w.since)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out.Windows = append(out.Windows, win)
|
|
}
|
|
|
|
byType, err := s.loadByActionType()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out.ByActionType = byType
|
|
|
|
topRules, err := s.loadTopRules(8)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out.TopRules = topRules
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func (s *ActivityService) loadWindow(key, label string, since *time.Time) (ActivityWindow, error) {
|
|
win := ActivityWindow{Key: key, Label: label}
|
|
query := `
|
|
SELECT COUNT(*),
|
|
` + caseSum("ra.status = 'Succeeded'") + `,
|
|
` + caseSum("ra.status = 'Failed'") + `,
|
|
` + caseSum("ra.action_type IN "+inList(syncActionTypes)) + `,
|
|
` + caseSum("ra.action_type IN "+inList(removalActionTypes)) + `,
|
|
COUNT(DISTINCT ra.object_dn)
|
|
FROM rule_run_actions ra`
|
|
var args []any
|
|
if since != nil {
|
|
query += ` WHERE ra.created_utc >= ?`
|
|
args = append(args, since.Format(time.RFC3339))
|
|
}
|
|
var syncs, removals int
|
|
if err := s.db.QueryRow(query, args...).Scan(
|
|
&win.Total, &win.Success, &win.Failed, &syncs, &removals, &win.ObjectsAffected,
|
|
); err != nil {
|
|
return win, err
|
|
}
|
|
win.Syncs = syncs
|
|
win.Removals = removals
|
|
win.Operations = win.Total - syncs - removals
|
|
return win, nil
|
|
}
|
|
|
|
func (s *ActivityService) loadByActionType() ([]ActionTypeCount, error) {
|
|
rows, err := s.db.Query(`
|
|
SELECT ra.action_type, COUNT(*),
|
|
` + caseSum("ra.status = 'Succeeded'") + `,
|
|
` + caseSum("ra.status = 'Failed'") + `
|
|
FROM rule_run_actions ra
|
|
GROUP BY ra.action_type
|
|
ORDER BY COUNT(*) DESC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]ActionTypeCount, 0)
|
|
for rows.Next() {
|
|
var c ActionTypeCount
|
|
if err := rows.Scan(&c.ActionType, &c.Total, &c.Success, &c.Failed); err != nil {
|
|
return nil, err
|
|
}
|
|
c.Category = categorize(c.ActionType)
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ActivityService) loadTopRules(limit int) ([]RuleActivityCount, error) {
|
|
rows, err := s.db.Query(`
|
|
SELECT r.id, r.name, COUNT(*),
|
|
`+caseSum("ra.status = 'Failed'")+`
|
|
FROM rule_run_actions ra
|
|
JOIN rule_runs rr ON rr.id = ra.rule_run_id
|
|
JOIN rules r ON r.id = rr.rule_id
|
|
GROUP BY r.id, r.name
|
|
ORDER BY COUNT(*) DESC
|
|
LIMIT ?`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]RuleActivityCount, 0)
|
|
for rows.Next() {
|
|
var c RuleActivityCount
|
|
if err := rows.Scan(&c.RuleID, &c.RuleName, &c.Total, &c.Failed); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ListActions returns a filtered, paginated slice of the action feed plus the
|
|
// total matching count.
|
|
func (s *ActivityService) ListActions(f ActivityFilter) ([]ActivityRecord, int, error) {
|
|
where := []string{"1=1"}
|
|
var args []any
|
|
|
|
switch f.Category {
|
|
case CategorySync:
|
|
where = append(where, "ra.action_type IN "+inList(syncActionTypes))
|
|
case CategoryRemoval:
|
|
where = append(where, "ra.action_type IN "+inList(removalActionTypes))
|
|
case CategoryOperation:
|
|
where = append(where, "ra.action_type NOT IN "+inList(append(append([]string{}, syncActionTypes...), removalActionTypes...)))
|
|
}
|
|
if f.ActionType != "" {
|
|
where = append(where, "ra.action_type = ?")
|
|
args = append(args, f.ActionType)
|
|
}
|
|
if f.Status != "" {
|
|
where = append(where, "ra.status = ?")
|
|
args = append(args, f.Status)
|
|
}
|
|
if f.RuleID != "" {
|
|
where = append(where, "rr.rule_id = ?")
|
|
args = append(args, f.RuleID)
|
|
}
|
|
if f.Search != "" {
|
|
where = append(where, "ra.object_dn LIKE ?")
|
|
args = append(args, "%"+f.Search+"%")
|
|
}
|
|
clause := strings.Join(where, " AND ")
|
|
|
|
var total int
|
|
countQuery := `
|
|
SELECT COUNT(*)
|
|
FROM rule_run_actions ra
|
|
JOIN rule_runs rr ON rr.id = ra.rule_run_id
|
|
JOIN rules r ON r.id = rr.rule_id
|
|
WHERE ` + clause
|
|
if err := s.db.QueryRow(countQuery, args...).Scan(&total); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
limit := f.Limit
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
listArgs := append(append([]any{}, args...), limit, f.Offset)
|
|
rows, err := s.db.Query(`
|
|
SELECT ra.id, ra.rule_run_id, rr.rule_id, r.name, ra.object_dn,
|
|
ra.action_type, ra.status, ra.details_json, ra.error_message,
|
|
ra.duration_ms, rr.triggered_by, ra.created_utc
|
|
FROM rule_run_actions ra
|
|
JOIN rule_runs rr ON rr.id = ra.rule_run_id
|
|
JOIN rules r ON r.id = rr.rule_id
|
|
WHERE `+clause+`
|
|
ORDER BY ra.created_utc DESC
|
|
LIMIT ? OFFSET ?`, listArgs...)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]ActivityRecord, 0, limit)
|
|
for rows.Next() {
|
|
var rec ActivityRecord
|
|
var details, errMsg, triggeredBy sql.NullString
|
|
var duration sql.NullInt64
|
|
if err := rows.Scan(
|
|
&rec.ID, &rec.RuleRunID, &rec.RuleID, &rec.RuleName, &rec.ObjectDN,
|
|
&rec.ActionType, &rec.Status, &details, &errMsg,
|
|
&duration, &triggeredBy, &rec.CreatedUTC,
|
|
); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
rec.Category = categorize(rec.ActionType)
|
|
if details.Valid {
|
|
v := details.String
|
|
rec.DetailsJSON = &v
|
|
}
|
|
if errMsg.Valid {
|
|
v := errMsg.String
|
|
rec.ErrorMessage = &v
|
|
}
|
|
if duration.Valid {
|
|
v := int(duration.Int64)
|
|
rec.DurationMS = &v
|
|
}
|
|
if triggeredBy.Valid {
|
|
v := triggeredBy.String
|
|
rec.TriggeredBy = &v
|
|
}
|
|
out = append(out, rec)
|
|
}
|
|
return out, total, rows.Err()
|
|
}
|
|
|
|
func ptrTime(t time.Time) *time.Time { return &t }
|