diff --git a/core/api/analytics.go b/core/api/analytics.go new file mode 100644 index 0000000..639bb9d --- /dev/null +++ b/core/api/analytics.go @@ -0,0 +1,163 @@ +package api + +import ( + "net/http" + "strconv" + + "github.com/gaucho-racing/sentinel/core/model" + "github.com/gaucho-racing/sentinel/core/service" + "github.com/gin-gonic/gin" +) + +// requireAnalyticsAccess gates the analytics + audit endpoints. They expose +// team-wide aggregates and administrative history, so access is limited to +// first-party admin sessions (the dashboard UI) and internal automation +// (sentinel:all). Mirrors the GetApplicationSecret gate. +func requireAnalyticsAccess(c *gin.Context) { + Require(c, Any( + RequestTokenHasScope(c, "sentinel:all"), + RequestTokenHasAudience(c, "sentinel") && RequestUserIsAdmin(c), + )) +} + +// recordAudit writes an audit row for the current request, resolving the actor +// and client IP from context. Best-effort: never blocks or fails the handler. +func recordAudit(c *gin.Context, action model.AuditAction, targetType string, targetID string, metadata model.JSONMap) { + service.RecordAuditEvent(model.AuditEvent{ + ActorID: GetRequestTokenEntityID(c), + Action: string(action), + TargetType: targetType, + TargetID: targetID, + IPAddress: c.ClientIP(), + Metadata: metadata, + }) +} + +// queryInt reads an integer query param, falling back to def when absent or +// unparseable. +func queryInt(c *gin.Context, key string, def int) int { + if v := c.Query(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} + +func AnalyticsOverview(c *gin.Context) { + requireAnalyticsAccess(c) + overview, err := service.GetAnalyticsOverview() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, overview) +} + +func AnalyticsLoginTimeSeries(c *gin.Context) { + requireAnalyticsAccess(c) + series, err := service.GetLoginTimeSeries(queryInt(c, "days", 30)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, series) +} + +func AnalyticsLoginHeatmap(c *gin.Context) { + requireAnalyticsAccess(c) + cells, err := service.GetLoginHeatmap(queryInt(c, "days", 90)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, cells) +} + +func AnalyticsTopApplications(c *gin.Context) { + requireAnalyticsAccess(c) + apps, err := service.GetTopApplications(queryInt(c, "days", 30), queryInt(c, "limit", 10)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, apps) +} + +func AnalyticsUserGrowth(c *gin.Context) { + requireAnalyticsAccess(c) + growth, err := service.GetUserGrowth(queryInt(c, "months", 12)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, growth) +} + +func AnalyticsMemberDemographics(c *gin.Context) { + requireAnalyticsAccess(c) + demographics, err := service.GetMemberDemographics(queryInt(c, "major_limit", 10)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, demographics) +} + +func AnalyticsAuthMethods(c *gin.Context) { + requireAnalyticsAccess(c) + breakdown, err := service.GetAuthMethodBreakdown() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, breakdown) +} + +func AnalyticsGroupMembership(c *gin.Context) { + requireAnalyticsAccess(c) + breakdown, err := service.GetGroupMembershipBreakdown() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, breakdown) +} + +func AnalyticsJoinRequests(c *gin.Context) { + requireAnalyticsAccess(c) + funnel, err := service.GetJoinRequestFunnel(queryInt(c, "days", 90)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, funnel) +} + +func AnalyticsAuditEvents(c *gin.Context) { + requireAnalyticsAccess(c) + events, err := service.GetAuditEvents(service.AuditEventsFilter{ + ActorID: c.Query("actor_id"), + Action: c.Query("action"), + TargetType: c.Query("target_type"), + TargetID: c.Query("target_id"), + Before: c.Query("before"), + After: c.Query("after"), + Limit: c.Query("limit"), + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, events) +} + +func AnalyticsAuditSummary(c *gin.Context) { + requireAnalyticsAccess(c) + summary, err := service.GetAuditActionSummary(queryInt(c, "days", 30)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, summary) +} diff --git a/core/api/api.go b/core/api/api.go index d79d04e..09e3934 100644 --- a/core/api/api.go +++ b/core/api/api.go @@ -128,6 +128,18 @@ func InitializeRoutes(router *gin.Engine) { router.POST("/groups/:id/requests/:requestID/comments", CreateJoinRequestComment) router.DELETE("/groups/:id/requests/:requestID/comments/:commentID", DeleteJoinRequestComment) + + router.GET("/analytics/overview", AnalyticsOverview) + router.GET("/analytics/logins/timeseries", AnalyticsLoginTimeSeries) + router.GET("/analytics/logins/heatmap", AnalyticsLoginHeatmap) + router.GET("/analytics/applications/top", AnalyticsTopApplications) + router.GET("/analytics/users/growth", AnalyticsUserGrowth) + router.GET("/analytics/members/demographics", AnalyticsMemberDemographics) + router.GET("/analytics/auth-methods", AnalyticsAuthMethods) + router.GET("/analytics/groups/membership", AnalyticsGroupMembership) + router.GET("/analytics/groups/join-requests", AnalyticsJoinRequests) + router.GET("/analytics/audit", AnalyticsAuditEvents) + router.GET("/analytics/audit/summary", AnalyticsAuditSummary) } func AuthChecker() gin.HandlerFunc { diff --git a/core/api/application.go b/core/api/application.go index 38b26fd..3f42202 100644 --- a/core/api/application.go +++ b/core/api/application.go @@ -124,6 +124,7 @@ func CreateApplication(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + recordAudit(c, model.AuditActionApplicationCreated, "application", app.ID, model.JSONMap{"name": app.Name}) c.JSON(http.StatusOK, createdApplicationResponse{ Application: app, Secret: app.ClientSecret, @@ -184,6 +185,7 @@ func GetApplicationSecret(c *gin.Context) { RequestTokenHasAudience(c, "sentinel") && RequestTokenHasEntityID(c, app.OwnerID), RequestTokenHasAudience(c, "sentinel") && RequestUserIsAdmin(c), )) + recordAudit(c, model.AuditActionApplicationSecretRevealed, "application", app.ID, model.JSONMap{"name": app.Name}) c.JSON(http.StatusOK, gin.H{"client_secret": app.ClientSecret}) } @@ -203,6 +205,7 @@ func DeleteApplication(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + recordAudit(c, model.AuditActionApplicationDeleted, "application", id, model.JSONMap{"name": existing.Name}) c.JSON(http.StatusOK, gin.H{"message": "application deleted"}) } diff --git a/core/api/group.go b/core/api/group.go index e7b5ecc..136d459 100644 --- a/core/api/group.go +++ b/core/api/group.go @@ -334,6 +334,10 @@ func AddGroupMember(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + recordAudit(c, model.AuditActionGroupMemberAdded, "group", id, model.JSONMap{ + "entity_id": req.EntityID, + "source": source, + }) // The entity's group set just changed — re-evaluate any conditional // bindings that depend on it. Conditional sync runs in the background // via syncJob; failures here are logged, not surfaced to the caller. @@ -352,6 +356,10 @@ func RemoveGroupMember(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + recordAudit(c, model.AuditActionGroupMemberRemoved, "group", id, model.JSONMap{ + "entity_id": entityID, + "source": source, + }) // Their group set just changed — re-evaluate conditional bindings. service.ReconcileConditionalForEntity(entityID) c.JSON(http.StatusOK, gin.H{"message": "member removed from group"}) @@ -585,6 +593,10 @@ func ApproveGroupJoinRequest(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + recordAudit(c, model.AuditActionJoinRequestApproved, "join_request", requestID, model.JSONMap{ + "group_id": request.GroupID, + "entity_id": request.EntityID, + }) c.JSON(http.StatusOK, request) } @@ -616,6 +628,10 @@ func RejectGroupJoinRequest(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + recordAudit(c, model.AuditActionJoinRequestRejected, "join_request", requestID, model.JSONMap{ + "group_id": request.GroupID, + "entity_id": request.EntityID, + }) c.JSON(http.StatusOK, request) } diff --git a/core/database/db.go b/core/database/db.go index 5170bc0..7fa88db 100644 --- a/core/database/db.go +++ b/core/database/db.go @@ -51,6 +51,7 @@ func Init() { &model.GroupOwner{}, &model.GroupConditionalBinding{}, &model.SigningKey{}, + &model.AuditEvent{}, ) logger.SugarLogger.Infoln("AutoMigration complete") DB = db diff --git a/core/model/audit_event.go b/core/model/audit_event.go new file mode 100644 index 0000000..dc57995 --- /dev/null +++ b/core/model/audit_event.go @@ -0,0 +1,38 @@ +package model + +import "time" + +// AuditAction enumerates the mutating actions Sentinel records to the audit +// trail. Values are stable strings — they're persisted and queried by the +// analytics layer, so renaming one is a breaking change. +type AuditAction string + +const ( + AuditActionApplicationCreated AuditAction = "APPLICATION_CREATED" + AuditActionApplicationDeleted AuditAction = "APPLICATION_DELETED" + AuditActionApplicationSecretRevealed AuditAction = "APPLICATION_SECRET_REVEALED" + AuditActionGroupMemberAdded AuditAction = "GROUP_MEMBER_ADDED" + AuditActionGroupMemberRemoved AuditAction = "GROUP_MEMBER_REMOVED" + AuditActionJoinRequestApproved AuditAction = "JOIN_REQUEST_APPROVED" + AuditActionJoinRequestRejected AuditAction = "JOIN_REQUEST_REJECTED" +) + +// AuditEvent is one recorded administrative action. Rows are written +// best-effort by the API layer (a failed write is logged, never surfaced to +// the caller) and read back by the analytics endpoints for the audit/security +// views. Metadata carries action-specific context (target name, affected +// entity, source, etc.) without needing a column per action. +type AuditEvent struct { + ID string `json:"id" gorm:"primaryKey"` + ActorID string `json:"actor_id" gorm:"index"` + Action string `json:"action" gorm:"index"` + TargetType string `json:"target_type" gorm:"index"` + TargetID string `json:"target_id" gorm:"index"` + IPAddress string `json:"ip_address"` + Metadata JSONMap `json:"metadata" gorm:"type:jsonb"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` +} + +func (AuditEvent) TableName() string { + return "audit_event" +} diff --git a/core/service/analytics.go b/core/service/analytics.go new file mode 100644 index 0000000..7ba0280 --- /dev/null +++ b/core/service/analytics.go @@ -0,0 +1,368 @@ +package service + +import ( + "time" + + "github.com/gaucho-racing/sentinel/core/database" + "github.com/gaucho-racing/sentinel/core/model" +) + +// CategoryCount is a generic label/value pair used by the breakdown charts +// (grad year, major, auth method, audit action, etc.). +type CategoryCount struct { + Label string `json:"label"` + Count int64 `json:"count"` +} + +// AnalyticsOverview is the set of headline KPIs shown as stat cards at the top +// of the analytics hub. +type AnalyticsOverview struct { + TotalUsers int64 `json:"total_users"` + TotalServiceAccounts int64 `json:"total_service_accounts"` + TotalApplications int64 `json:"total_applications"` + TotalGroups int64 `json:"total_groups"` + NewUsers30d int64 `json:"new_users_30d"` + Logins24h int64 `json:"logins_24h"` + Logins7d int64 `json:"logins_7d"` + Logins30d int64 `json:"logins_30d"` + ActiveUsers7d int64 `json:"active_users_7d"` + ActiveUsers30d int64 `json:"active_users_30d"` + PendingJoinRequests int64 `json:"pending_join_requests"` + AuditEvents7d int64 `json:"audit_events_7d"` +} + +func GetAnalyticsOverview() (AnalyticsOverview, error) { + var o AnalyticsOverview + now := time.Now() + db := database.DB + + db.Model(&model.User{}).Count(&o.TotalUsers) + db.Model(&model.Entity{}).Where("type = ?", model.EntityTypeServiceAccount).Count(&o.TotalServiceAccounts) + db.Model(&model.Application{}).Count(&o.TotalApplications) + db.Model(&model.Group{}).Count(&o.TotalGroups) + db.Model(&model.User{}).Where("created_at > ?", now.AddDate(0, 0, -30)).Count(&o.NewUsers30d) + + db.Model(&model.EntityLogin{}).Where("created_at > ?", now.Add(-24*time.Hour)).Count(&o.Logins24h) + db.Model(&model.EntityLogin{}).Where("created_at > ?", now.AddDate(0, 0, -7)).Count(&o.Logins7d) + db.Model(&model.EntityLogin{}).Where("created_at > ?", now.AddDate(0, 0, -30)).Count(&o.Logins30d) + db.Model(&model.EntityLogin{}).Where("created_at > ?", now.AddDate(0, 0, -7)).Distinct("entity_id").Count(&o.ActiveUsers7d) + db.Model(&model.EntityLogin{}).Where("created_at > ?", now.AddDate(0, 0, -30)).Distinct("entity_id").Count(&o.ActiveUsers30d) + + db.Model(&model.GroupJoinRequest{}).Where("status = ?", model.GroupJoinRequestStatusPending).Count(&o.PendingJoinRequests) + db.Model(&model.AuditEvent{}).Where("created_at > ?", now.AddDate(0, 0, -7)).Count(&o.AuditEvents7d) + + return o, nil +} + +// LoginPoint is one day in the sign-in trend series. +type LoginPoint struct { + Date string `json:"date"` // YYYY-MM-DD (UTC) + Logins int64 `json:"logins"` + UniqueUsers int64 `json:"unique_users"` +} + +// GetLoginTimeSeries returns per-day login counts and distinct-user counts for +// the trailing `days` window, gap-filled so every calendar day is present +// (charts render a continuous axis without client-side interpolation). +func GetLoginTimeSeries(days int) ([]LoginPoint, error) { + if days <= 0 { + days = 30 + } + now := time.Now().UTC() + startDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, -(days - 1)) + + type row struct { + Day string + Logins int64 + UniqueUsers int64 + } + rows := []row{} + sql := ` + SELECT TO_CHAR(created_at, 'YYYY-MM-DD') AS day, + COUNT(*) AS logins, + COUNT(DISTINCT entity_id) AS unique_users + FROM entity_login + WHERE created_at >= ? + GROUP BY day + ORDER BY day + ` + if err := database.DB.Raw(sql, startDay).Scan(&rows).Error; err != nil { + return []LoginPoint{}, err + } + + byDay := map[string]row{} + for _, r := range rows { + byDay[r.Day] = r + } + out := make([]LoginPoint, 0, days) + for i := 0; i < days; i++ { + d := startDay.AddDate(0, 0, i).Format("2006-01-02") + if r, ok := byDay[d]; ok { + out = append(out, LoginPoint{Date: d, Logins: r.Logins, UniqueUsers: r.UniqueUsers}) + } else { + out = append(out, LoginPoint{Date: d}) + } + } + return out, nil +} + +// HeatmapCell is one (weekday, hour) bucket of sign-in volume. Weekday is +// 0=Sunday..6=Saturday, hour is 0..23, both in UTC. +type HeatmapCell struct { + Weekday int `json:"weekday"` + Hour int `json:"hour"` + Count int64 `json:"count"` +} + +func GetLoginHeatmap(days int) ([]HeatmapCell, error) { + if days <= 0 { + days = 90 + } + start := time.Now().AddDate(0, 0, -days) + cells := []HeatmapCell{} + sql := ` + SELECT CAST(EXTRACT(DOW FROM created_at) AS INTEGER) AS weekday, + CAST(EXTRACT(HOUR FROM created_at) AS INTEGER) AS hour, + COUNT(*) AS count + FROM entity_login + WHERE created_at >= ? + GROUP BY weekday, hour + ` + if err := database.DB.Raw(sql, start).Scan(&cells).Error; err != nil { + return []HeatmapCell{}, err + } + return cells, nil +} + +// TopApplication ranks an app by sign-in volume over the window. Name/IconURL +// are left-joined so logins against a deleted or unknown client_id still show +// (falling back to the raw client_id as the label). +type TopApplication struct { + ClientID string `json:"client_id"` + Name string `json:"name"` + IconURL string `json:"icon_url"` + Logins int64 `json:"logins"` + UniqueUsers int64 `json:"unique_users"` +} + +func GetTopApplications(days int, limit int) ([]TopApplication, error) { + if days <= 0 { + days = 30 + } + if limit <= 0 { + limit = 10 + } + start := time.Now().AddDate(0, 0, -days) + apps := []TopApplication{} + sql := ` + SELECT l.client_id AS client_id, + COALESCE(NULLIF(a.name, ''), l.client_id) AS name, + COALESCE(a.icon_url, '') AS icon_url, + COUNT(*) AS logins, + COUNT(DISTINCT l.entity_id) AS unique_users + FROM entity_login l + LEFT JOIN application a ON a.client_id = l.client_id + WHERE l.created_at >= ? + GROUP BY l.client_id, a.name, a.icon_url + ORDER BY logins DESC + LIMIT ? + ` + if err := database.DB.Raw(sql, start, limit).Scan(&apps).Error; err != nil { + return []TopApplication{}, err + } + return apps, nil +} + +// UserGrowthPoint is one month of member growth: new signups that month plus +// the running total of all members through that month. +type UserGrowthPoint struct { + Date string `json:"date"` // YYYY-MM (UTC) + NewUsers int64 `json:"new_users"` + Cumulative int64 `json:"cumulative"` +} + +func GetUserGrowth(months int) ([]UserGrowthPoint, error) { + if months <= 0 { + months = 12 + } + now := time.Now().UTC() + startMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, -(months - 1), 0) + + // Members created before the window form the cumulative baseline so the + // running total reflects the whole roster, not just the visible range. + var baseline int64 + database.DB.Model(&model.User{}).Where("created_at < ?", startMonth).Count(&baseline) + + type row struct { + Month string + NewUsers int64 + } + rows := []row{} + sql := ` + SELECT TO_CHAR(created_at, 'YYYY-MM') AS month, COUNT(*) AS new_users + FROM "user" + WHERE created_at >= ? + GROUP BY month + ORDER BY month + ` + if err := database.DB.Raw(sql, startMonth).Scan(&rows).Error; err != nil { + return []UserGrowthPoint{}, err + } + + byMonth := map[string]int64{} + for _, r := range rows { + byMonth[r.Month] = r.NewUsers + } + out := make([]UserGrowthPoint, 0, months) + cum := baseline + for i := 0; i < months; i++ { + m := startMonth.AddDate(0, i, 0).Format("2006-01") + n := byMonth[m] + cum += n + out = append(out, UserGrowthPoint{Date: m, NewUsers: n, Cumulative: cum}) + } + return out, nil +} + +// MemberDemographics bundles the member breakdowns the officer-facing view +// cares about into a single response. +type MemberDemographics struct { + ByGradYear []CategoryCount `json:"by_grad_year"` + ByMajor []CategoryCount `json:"by_major"` + ByGraduateLevel []CategoryCount `json:"by_graduate_level"` +} + +func GetMemberDemographics(majorLimit int) (MemberDemographics, error) { + if majorLimit <= 0 { + majorLimit = 10 + } + var d MemberDemographics + + d.ByGradYear = []CategoryCount{} + if err := database.DB.Raw(` + SELECT CAST(graduation_year AS TEXT) AS label, COUNT(*) AS count + FROM "user" + WHERE graduation_year > 0 + GROUP BY graduation_year + ORDER BY graduation_year + `).Scan(&d.ByGradYear).Error; err != nil { + return MemberDemographics{}, err + } + + d.ByMajor = []CategoryCount{} + if err := database.DB.Raw(` + SELECT major AS label, COUNT(*) AS count + FROM "user" + WHERE major <> '' + GROUP BY major + ORDER BY count DESC + LIMIT ? + `, majorLimit).Scan(&d.ByMajor).Error; err != nil { + return MemberDemographics{}, err + } + + d.ByGraduateLevel = []CategoryCount{} + if err := database.DB.Raw(` + SELECT graduate_level AS label, COUNT(*) AS count + FROM "user" + WHERE graduate_level <> '' + GROUP BY graduate_level + ORDER BY count DESC + `).Scan(&d.ByGraduateLevel).Error; err != nil { + return MemberDemographics{}, err + } + + return d, nil +} + +// AuthMethodBreakdown counts distinct entities holding each authentication +// method. An entity can appear in more than one bucket (multi-auth), which is +// exactly the signal this view surfaces. +type AuthMethodBreakdown struct { + Email int64 `json:"email"` + Phone int64 `json:"phone"` + Discord int64 `json:"discord"` + Google int64 `json:"google"` + GitHub int64 `json:"github"` +} + +func GetAuthMethodBreakdown() (AuthMethodBreakdown, error) { + var b AuthMethodBreakdown + db := database.DB + db.Model(&model.EntityEmail{}).Distinct("entity_id").Count(&b.Email) + db.Model(&model.EntityPhone{}).Distinct("entity_id").Count(&b.Phone) + db.Model(&model.EntityExternalAuth{}).Where("provider = ?", model.ExternalAuthProviderDiscord).Distinct("entity_id").Count(&b.Discord) + db.Model(&model.EntityExternalAuth{}).Where("provider = ?", model.ExternalAuthProviderGoogle).Distinct("entity_id").Count(&b.Google) + db.Model(&model.EntityExternalAuth{}).Where("provider = ?", model.ExternalAuthProviderGitHub).Distinct("entity_id").Count(&b.GitHub) + return b, nil +} + +// GroupMembership is per-group membership sizing split by member source. Powers +// the stacked group breakdown (DIRECT vs CONDITIONAL vs DISCORD). +type GroupMembership struct { + GroupID string `json:"group_id"` + Name string `json:"name"` + MemberCount int64 `json:"member_count"` + Direct int64 `json:"direct"` + Conditional int64 `json:"conditional"` + Discord int64 `json:"discord"` +} + +func GetGroupMembershipBreakdown() ([]GroupMembership, error) { + out := []GroupMembership{} + sql := ` + SELECT g.id AS group_id, + g.name AS name, + COUNT(m.entity_id) AS member_count, + COUNT(m.entity_id) FILTER (WHERE m.source = 'DIRECT') AS direct, + COUNT(m.entity_id) FILTER (WHERE m.source = 'CONDITIONAL') AS conditional, + COUNT(m.entity_id) FILTER (WHERE m.source = 'DISCORD') AS discord + FROM "group" g + LEFT JOIN group_member m ON m.group_id = g.id + GROUP BY g.id, g.name + ORDER BY member_count DESC + ` + if err := database.DB.Raw(sql).Scan(&out).Error; err != nil { + return []GroupMembership{}, err + } + return out, nil +} + +// JoinRequestFunnel summarizes join-request throughput over the window. Pending +// is point-in-time (the current backlog); approved/rejected are counted within +// the window; the median decision time is in hours. +type JoinRequestFunnel struct { + Pending int64 `json:"pending"` + Approved int64 `json:"approved"` + Rejected int64 `json:"rejected"` + MedianDecisionHours float64 `json:"median_decision_hours"` +} + +func GetJoinRequestFunnel(days int) (JoinRequestFunnel, error) { + if days <= 0 { + days = 90 + } + start := time.Now().AddDate(0, 0, -days) + var f JoinRequestFunnel + db := database.DB + db.Model(&model.GroupJoinRequest{}).Where("status = ?", model.GroupJoinRequestStatusPending).Count(&f.Pending) + db.Model(&model.GroupJoinRequest{}).Where("status = ? AND created_at >= ?", model.GroupJoinRequestStatusApproved, start).Count(&f.Approved) + db.Model(&model.GroupJoinRequest{}).Where("status = ? AND created_at >= ?", model.GroupJoinRequestStatusRejected, start).Count(&f.Rejected) + + var median float64 + sql := ` + SELECT COALESCE( + percentile_cont(0.5) WITHIN GROUP ( + ORDER BY EXTRACT(EPOCH FROM (reviewed_at - created_at)) / 3600.0 + ), 0) + FROM group_join_request + WHERE status IN ('APPROVED', 'REJECTED') + AND reviewed_at > created_at + AND created_at >= ? + ` + if err := database.DB.Raw(sql, start).Row().Scan(&median); err != nil { + return f, err + } + f.MedianDecisionHours = median + return f, nil +} diff --git a/core/service/audit_event.go b/core/service/audit_event.go new file mode 100644 index 0000000..517b528 --- /dev/null +++ b/core/service/audit_event.go @@ -0,0 +1,97 @@ +package service + +import ( + "strconv" + "time" + + "github.com/gaucho-racing/sentinel/core/database" + "github.com/gaucho-racing/sentinel/core/model" + "github.com/gaucho-racing/sentinel/core/pkg/logger" + "github.com/gaucho-racing/ulid-go" +) + +// RecordAuditEvent persists an audit row best-effort. Callers invoke it after +// a mutation has already succeeded, so a write failure here must not change +// the request outcome — it's logged and swallowed. Returns nothing for the +// same reason: there is no error the caller should act on. +func RecordAuditEvent(event model.AuditEvent) { + if event.ID == "" { + event.ID = ulid.Make().Prefixed("aud") + } + if err := database.DB.Create(&event).Error; err != nil { + logger.SugarLogger.Errorf("Failed to record audit event %s: %v", event.Action, err) + } +} + +// AuditEventsFilter holds the query params accepted by GetAuditEvents. All +// string fields are optional; empty strings are ignored. Mirrors the shape of +// EntityLoginsFilter. +type AuditEventsFilter struct { + ActorID string + Action string + TargetType string + TargetID string + Before string // RFC3339; matches events with created_at < Before + After string // RFC3339; matches events with created_at > After + Limit string // integer string; unset defaults to 100 +} + +func GetAuditEvents(filter AuditEventsFilter) ([]model.AuditEvent, error) { + events := []model.AuditEvent{} + query := database.DB.Model(&model.AuditEvent{}) + if filter.ActorID != "" { + query = query.Where("actor_id = ?", filter.ActorID) + } + if filter.Action != "" { + query = query.Where("action = ?", filter.Action) + } + if filter.TargetType != "" { + query = query.Where("target_type = ?", filter.TargetType) + } + if filter.TargetID != "" { + query = query.Where("target_id = ?", filter.TargetID) + } + if filter.Before != "" { + if t, err := time.Parse(time.RFC3339, filter.Before); err == nil { + query = query.Where("created_at < ?", t) + } + } + if filter.After != "" { + if t, err := time.Parse(time.RFC3339, filter.After); err == nil { + query = query.Where("created_at > ?", t) + } + } + query = query.Order("created_at desc") + limit := 100 + if filter.Limit != "" { + if n, err := strconv.Atoi(filter.Limit); err == nil && n > 0 { + limit = n + } + } + query = query.Limit(limit) + if err := query.Find(&events).Error; err != nil { + return []model.AuditEvent{}, err + } + return events, nil +} + +// GetAuditActionSummary returns the count of audit events per action over the +// trailing window, most frequent first. Powers the audit breakdown chart. +func GetAuditActionSummary(days int) ([]CategoryCount, error) { + if days <= 0 { + days = 30 + } + start := time.Now().AddDate(0, 0, -days) + out := []CategoryCount{} + sql := ` + SELECT action AS label, COUNT(*) AS count + FROM audit_event + WHERE created_at >= ? + GROUP BY action + ORDER BY count DESC + ` + if err := database.DB.Raw(sql, start).Scan(&out).Error; err != nil { + return []CategoryCount{}, err + } + return out, nil +} diff --git a/web/package-lock.json b/web/package-lock.json index dc5143f..71bafcf 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -24,6 +24,7 @@ "react-dom": "^19.2.5", "react-international-phone": "^4.8.0", "react-router-dom": "^7.14.2", + "recharts": "^3.10.1", "shadcn": "^4.5.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", @@ -2745,6 +2746,32 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", @@ -3012,6 +3039,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", @@ -3316,6 +3355,69 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -3381,6 +3483,12 @@ "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -4322,6 +4430,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -4348,6 +4577,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -4606,6 +4841,18 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.51.0.tgz", + "integrity": "sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4838,6 +5085,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -5638,6 +5891,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5670,6 +5933,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -7288,6 +7560,36 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -7430,6 +7732,51 @@ "node": ">= 4" } }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7448,6 +7795,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -8364,6 +8717,28 @@ "node": ">= 0.8" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", diff --git a/web/package.json b/web/package.json index de7e6eb..b06a452 100644 --- a/web/package.json +++ b/web/package.json @@ -26,6 +26,7 @@ "react-dom": "^19.2.5", "react-international-phone": "^4.8.0", "react-router-dom": "^7.14.2", + "recharts": "^3.10.1", "shadcn": "^4.5.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", diff --git a/web/src/components/AppSidebar.tsx b/web/src/components/AppSidebar.tsx index a7623bf..1ebef3d 100644 --- a/web/src/components/AppSidebar.tsx +++ b/web/src/components/AppSidebar.tsx @@ -12,12 +12,13 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar" +import { useAdmins } from "@/lib/admin" const NAV_ITEMS = [ { to: "/", label: "Dashboard", icon: LayoutDashboard }, { to: "/applications", label: "Applications", icon: Boxes }, { to: "/groups", label: "Groups", icon: Users }, - { to: "/analytics", label: "Analytics", icon: BarChart3 }, + { to: "/analytics", label: "Analytics", icon: BarChart3, adminOnly: true }, { to: "/settings", label: "Settings", icon: Settings }, { to: "/debug", label: "Debug", icon: Bug }, ] @@ -29,6 +30,9 @@ function isActive(currentPath: string, target: string) { export function AppSidebar() { const { pathname } = useLocation() + const { isAdmin } = useAdmins() + + const navItems = NAV_ITEMS.filter((item) => !item.adminOnly || isAdmin) return ( @@ -43,7 +47,7 @@ export function AppSidebar() { - {NAV_ITEMS.map((item) => ( + {navItems.map((item) => ( diff --git a/web/src/components/analytics/primitives.tsx b/web/src/components/analytics/primitives.tsx new file mode 100644 index 0000000..e76261c --- /dev/null +++ b/web/src/components/analytics/primitives.tsx @@ -0,0 +1,221 @@ +import type { ComponentType, ReactNode } from "react" + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { cn } from "@/lib/utils" + +export function StatCard({ + label, + value, + sub, + icon: Icon, +}: { + label: string + value: ReactNode + sub?: string + icon?: ComponentType<{ className?: string }> +}) { + return ( + + +
+

+ {label} +

+ {Icon && } +
+

{value}

+ {sub &&

{sub}

} +
+
+ ) +} + +export function StatCardSkeleton() { + return ( + + + + + + + + ) +} + +// ChartCard is the boxed container every chart lives in. It owns the header, +// the fixed-height plot area, and the loading/empty states — children are +// expected to render their own . +export function ChartCard({ + title, + description, + action, + isLoading, + isEmpty, + emptyText = "No data for this range yet.", + height = 300, + className, + children, +}: { + title: string + description?: string + action?: ReactNode + isLoading?: boolean + isEmpty?: boolean + emptyText?: string + height?: number + className?: string + children: ReactNode +}) { + return ( + + +
+ {title} + {description && {description}} +
+ {action} +
+ + {isLoading ? ( + + ) : isEmpty ? ( +
+ {emptyText} +
+ ) : ( +
{children}
+ )} +
+
+ ) +} + +// ChartTooltip is a themed replacement for recharts' default tooltip. Pass it +// to a } /> — recharts injects active/payload. +export function ChartTooltip({ + active, + payload, + label, + labelFormatter, +}: { + active?: boolean + payload?: Array<{ name?: string; value?: number | string; color?: string; dataKey?: string }> + label?: string | number + labelFormatter?: (label: string | number) => string +}) { + if (!active || !payload || payload.length === 0) return null + return ( +
+ {label !== undefined && ( +

+ {labelFormatter ? labelFormatter(label) : label} +

+ )} +
+ {payload.map((entry, i) => ( +
+ + {entry.name ?? entry.dataKey} + {entry.value} +
+ ))} +
+
+ ) +} + +// RangeToggle is a compact segmented control for picking a time window. Values +// are opaque to the control — the parent decides what the numbers mean (days, +// months) and how to query. +export function RangeToggle({ + value, + onChange, + options, +}: { + value: T + onChange: (value: T) => void + options: Array<{ value: T; label: string }> +}) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ) +} + +const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + +// Heatmap renders a weekday × hour grid of sign-in volume. Intensity is the +// cell count scaled against the busiest cell, tinted with the brand pink. +export function Heatmap({ cells }: { cells: Array<{ weekday: number; hour: number; count: number }> }) { + const grid = new Map() + let max = 0 + for (const c of cells) { + grid.set(`${c.weekday}-${c.hour}`, c.count) + if (c.count > max) max = c.count + } + + const intensity = (count: number) => { + if (count <= 0) return "var(--color-muted)" + const alpha = 0.15 + 0.85 * (count / max) + return `rgba(225, 5, 163, ${alpha.toFixed(3)})` + } + + return ( +
+
+
+ {WEEKDAYS.map((day, wd) => ( +
+ {day} +
+ {Array.from({ length: 24 }).map((_, hour) => { + const count = grid.get(`${wd}-${hour}`) ?? 0 + return ( +
+ ) + })} +
+
+ ))} +
+ +
+ 12a + 6a + 12p + 6p + 11p +
+
+
+
+
+ ) +} diff --git a/web/src/components/analytics/utils.ts b/web/src/components/analytics/utils.ts new file mode 100644 index 0000000..82cbee0 --- /dev/null +++ b/web/src/components/analytics/utils.ts @@ -0,0 +1,37 @@ +// Shared, non-component helpers for the analytics views. Kept out of +// primitives.tsx so that file can export only components (react-refresh). + +// Brand-forward categorical palette. Ordered so the first two are the Gaucho +// Racing brand colors; the rest are chosen to stay legible on both the light +// and dark card backgrounds. +export const PALETTE = [ + "#e105a3", // gr-pink + "#8412fc", // gr-purple + "#0ea5e9", // sky + "#22c55e", // green + "#f59e0b", // amber + "#14b8a6", // teal + "#6366f1", // indigo + "#ef4444", // red +] + +// Theme tokens usable directly as SVG stroke/fill values inside recharts. +export const AXIS_COLOR = "var(--color-muted-foreground)" +export const GRID_COLOR = "var(--color-border)" + +export function formatNumber(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}k` + return `${n}` +} + +// Humanizes an audit action enum (APPLICATION_SECRET_REVEALED) into a title +// ("Application Secret Revealed"). Accepts number too so it can be handed +// straight to recharts tick/label formatters. +export function humanizeAction(action: string | number): string { + return String(action) + .toLowerCase() + .split("_") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" ") +} diff --git a/web/src/lib/analytics.ts b/web/src/lib/analytics.ts new file mode 100644 index 0000000..974aaec --- /dev/null +++ b/web/src/lib/analytics.ts @@ -0,0 +1,207 @@ +import { useQuery } from "@tanstack/react-query" + +import { api } from "@/lib/api" + +// Types mirror the JSON shapes returned by the core /analytics/* endpoints. + +export type AnalyticsOverview = { + total_users: number + total_service_accounts: number + total_applications: number + total_groups: number + new_users_30d: number + logins_24h: number + logins_7d: number + logins_30d: number + active_users_7d: number + active_users_30d: number + pending_join_requests: number + audit_events_7d: number +} + +export type LoginPoint = { + date: string + logins: number + unique_users: number +} + +export type HeatmapCell = { + weekday: number + hour: number + count: number +} + +export type TopApplication = { + client_id: string + name: string + icon_url: string + logins: number + unique_users: number +} + +export type UserGrowthPoint = { + date: string + new_users: number + cumulative: number +} + +export type CategoryCount = { + label: string + count: number +} + +export type MemberDemographics = { + by_grad_year: CategoryCount[] + by_major: CategoryCount[] + by_graduate_level: CategoryCount[] +} + +export type AuthMethodBreakdown = { + email: number + phone: number + discord: number + google: number + github: number +} + +export type GroupMembership = { + group_id: string + name: string + member_count: number + direct: number + conditional: number + discord: number +} + +export type JoinRequestFunnel = { + pending: number + approved: number + rejected: number + median_decision_hours: number +} + +export type AuditEvent = { + id: string + actor_id: string + action: string + target_type: string + target_id: string + ip_address: string + metadata: Record | null + created_at: string +} + +const STALE = 5 * 60 * 1000 + +export function useAnalyticsOverview(enabled = true) { + return useQuery({ + queryKey: ["analytics", "overview"], + queryFn: async () => (await api.get("/analytics/overview")).data, + staleTime: STALE, + enabled, + }) +} + +export function useLoginTimeSeries(days: number, enabled = true) { + return useQuery({ + queryKey: ["analytics", "logins", "timeseries", days], + queryFn: async () => + (await api.get("/analytics/logins/timeseries", { params: { days } })).data, + staleTime: STALE, + enabled, + }) +} + +export function useLoginHeatmap(days: number, enabled = true) { + return useQuery({ + queryKey: ["analytics", "logins", "heatmap", days], + queryFn: async () => + (await api.get("/analytics/logins/heatmap", { params: { days } })).data, + staleTime: STALE, + enabled, + }) +} + +export function useTopApplications(days: number, limit = 10, enabled = true) { + return useQuery({ + queryKey: ["analytics", "applications", "top", days, limit], + queryFn: async () => + (await api.get("/analytics/applications/top", { params: { days, limit } })) + .data, + staleTime: STALE, + enabled, + }) +} + +export function useUserGrowth(months: number, enabled = true) { + return useQuery({ + queryKey: ["analytics", "users", "growth", months], + queryFn: async () => + (await api.get("/analytics/users/growth", { params: { months } })).data, + staleTime: STALE, + enabled, + }) +} + +export function useMemberDemographics(enabled = true) { + return useQuery({ + queryKey: ["analytics", "members", "demographics"], + queryFn: async () => + (await api.get("/analytics/members/demographics")).data, + staleTime: STALE, + enabled, + }) +} + +export function useAuthMethods(enabled = true) { + return useQuery({ + queryKey: ["analytics", "auth-methods"], + queryFn: async () => (await api.get("/analytics/auth-methods")).data, + staleTime: STALE, + enabled, + }) +} + +export function useGroupMembership(enabled = true) { + return useQuery({ + queryKey: ["analytics", "groups", "membership"], + queryFn: async () => + (await api.get("/analytics/groups/membership")).data, + staleTime: STALE, + enabled, + }) +} + +export function useJoinRequestFunnel(days: number, enabled = true) { + return useQuery({ + queryKey: ["analytics", "groups", "join-requests", days], + queryFn: async () => + (await api.get("/analytics/groups/join-requests", { params: { days } })) + .data, + staleTime: STALE, + enabled, + }) +} + +export function useAuditEvents( + filters: { action?: string; limit?: number } = {}, + enabled = true, +) { + return useQuery({ + queryKey: ["analytics", "audit", filters], + queryFn: async () => + (await api.get("/analytics/audit", { params: filters })).data, + staleTime: 60 * 1000, + enabled, + }) +} + +export function useAuditSummary(days: number, enabled = true) { + return useQuery({ + queryKey: ["analytics", "audit", "summary", days], + queryFn: async () => + (await api.get("/analytics/audit/summary", { params: { days } })).data, + staleTime: STALE, + enabled, + }) +} diff --git a/web/src/pages/analytics/AnalyticsPage.tsx b/web/src/pages/analytics/AnalyticsPage.tsx index 9cfae57..d8a8905 100644 --- a/web/src/pages/analytics/AnalyticsPage.tsx +++ b/web/src/pages/analytics/AnalyticsPage.tsx @@ -1,22 +1,89 @@ +import { Activity, BarChart3, Boxes, LogIn, ScrollText, Users, UsersRound } from "lucide-react" +import { useState } from "react" + import { PageContainer, PageHeader } from "@/components/PageContainer" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { useAdmins } from "@/lib/admin" +import { cn } from "@/lib/utils" + +import { ApplicationsTab } from "./tabs/ApplicationsTab" +import { AuditTab } from "./tabs/AuditTab" +import { GroupsTab } from "./tabs/GroupsTab" +import { MembersTab } from "./tabs/MembersTab" +import { OverviewTab } from "./tabs/OverviewTab" +import { SigninsTab } from "./tabs/SigninsTab" + +const TABS = [ + { key: "overview", label: "Overview", icon: Activity, Component: OverviewTab }, + { key: "signins", label: "Sign-ins", icon: LogIn, Component: SigninsTab }, + { key: "applications", label: "Applications", icon: Boxes, Component: ApplicationsTab }, + { key: "members", label: "Members", icon: Users, Component: MembersTab }, + { key: "groups", label: "Groups", icon: UsersRound, Component: GroupsTab }, + { key: "audit", label: "Audit", icon: ScrollText, Component: AuditTab }, +] as const + +type TabKey = (typeof TABS)[number]["key"] export default function AnalyticsPage() { + const [tab, setTab] = useState("overview") + const { isAdmin, isLoading } = useAdmins() + + const Active = TABS.find((t) => t.key === tab)?.Component ?? OverviewTab + return ( - - - Coming soon - Charts and aggregate metrics will live here. - - - Placeholder page during design phase. - - + + {isLoading ? ( +
+ +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+
+ ) : !isAdmin ? ( + + +
+ + Restricted +
+ Team analytics are available to admins. +
+ + Ask an existing admin to add you to the Admins group if you need access to sign-in, + membership, and audit insights. + +
+ ) : ( + <> +
+ {TABS.map((t) => ( + + ))} +
+ + + )}
) } diff --git a/web/src/pages/analytics/tabs/ApplicationsTab.tsx b/web/src/pages/analytics/tabs/ApplicationsTab.tsx new file mode 100644 index 0000000..699fb59 --- /dev/null +++ b/web/src/pages/analytics/tabs/ApplicationsTab.tsx @@ -0,0 +1,102 @@ +import { useState } from "react" +import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts" + +import { ChartCard, ChartTooltip, RangeToggle } from "@/components/analytics/primitives" +import { AXIS_COLOR, formatNumber, GRID_COLOR, PALETTE } from "@/components/analytics/utils" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { useTopApplications } from "@/lib/analytics" + +const RANGES: Array<{ value: number; label: string }> = [ + { value: 7, label: "7d" }, + { value: 30, label: "30d" }, + { value: 90, label: "90d" }, +] + +export function ApplicationsTab() { + const [days, setDays] = useState(30) + const top = useTopApplications(days, 12) + const data = top.data ?? [] + + return ( +
+ } + isLoading={top.isLoading} + isEmpty={data.length === 0} + emptyText="No application sign-ins in this range." + height={Math.max(240, data.length * 34)} + > + + + + + + } /> + + {data.map((_, i) => ( + + ))} + + + + + + + + Application breakdown + + + {top.isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : data.length === 0 ? ( +

Nothing to show.

+ ) : ( +
+ + + + + + + + + + {data.map((app) => ( + + + + + + ))} + +
ApplicationSign-insMembers
+
{app.name}
+
{app.client_id}
+
{formatNumber(app.logins)}{formatNumber(app.unique_users)}
+
+ )} +
+
+
+ ) +} diff --git a/web/src/pages/analytics/tabs/AuditTab.tsx b/web/src/pages/analytics/tabs/AuditTab.tsx new file mode 100644 index 0000000..7bab293 --- /dev/null +++ b/web/src/pages/analytics/tabs/AuditTab.tsx @@ -0,0 +1,168 @@ +import { useState } from "react" +import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts" + +import { ChartCard, ChartTooltip, RangeToggle } from "@/components/analytics/primitives" +import { AXIS_COLOR, GRID_COLOR, humanizeAction, PALETTE } from "@/components/analytics/utils" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { useAuditEvents, useAuditSummary, type AuditEvent } from "@/lib/analytics" +import { useUsers, userName } from "@/lib/users" + +const RANGES: Array<{ value: number; label: string }> = [ + { value: 7, label: "7d" }, + { value: 30, label: "30d" }, + { value: 90, label: "90d" }, +] + +const ALL = "ALL" + +// Actions that read/reveal rather than mutate are worth flagging in red. +const SENSITIVE = new Set(["APPLICATION_SECRET_REVEALED"]) + +function timestamp(iso: string) { + const d = new Date(iso) + return d.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }) +} + +function metaSummary(event: AuditEvent): string { + if (!event.metadata) return "" + return Object.entries(event.metadata) + .filter(([, v]) => v !== "" && v != null) + .map(([k, v]) => `${k}: ${String(v)}`) + .join(" · ") +} + +export function AuditTab() { + const [days, setDays] = useState(30) + const [action, setAction] = useState(ALL) + + const summary = useAuditSummary(days) + const events = useAuditEvents({ action: action === ALL ? undefined : action, limit: 100 }) + const users = useUsers() + + const actorName = (id: string) => { + const u = (users.data ?? []).find((x) => x.entity_id === id) + return u ? userName(u) : id + } + + const summaryData = summary.data ?? [] + + return ( +
+ } + isLoading={summary.isLoading} + isEmpty={summaryData.length === 0} + emptyText="No audited actions in this range yet." + height={Math.max(220, summaryData.length * 40)} + > + + + + + + } + /> + + {summaryData.map((row, i) => ( + + ))} + + + + + + + + Recent activity + + + + {events.isLoading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : (events.data ?? []).length === 0 ? ( +

+ No matching audit events. +

+ ) : ( +
    + {(events.data ?? []).map((event) => ( +
  • + + {humanizeAction(event.action)} + + {actorName(event.actor_id)} + {metaSummary(event) && ( + + {metaSummary(event)} + + )} + + {timestamp(event.created_at)} + +
  • + ))} +
+ )} +
+
+
+ ) +} diff --git a/web/src/pages/analytics/tabs/GroupsTab.tsx b/web/src/pages/analytics/tabs/GroupsTab.tsx new file mode 100644 index 0000000..82e5e82 --- /dev/null +++ b/web/src/pages/analytics/tabs/GroupsTab.tsx @@ -0,0 +1,121 @@ +import { useState } from "react" +import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts" + +import { + ChartCard, + ChartTooltip, + RangeToggle, + StatCard, + StatCardSkeleton, +} from "@/components/analytics/primitives" +import { AXIS_COLOR, GRID_COLOR, PALETTE } from "@/components/analytics/utils" +import { useGroupMembership, useJoinRequestFunnel } from "@/lib/analytics" + +const RANGES: Array<{ value: number; label: string }> = [ + { value: 30, label: "30d" }, + { value: 90, label: "90d" }, + { value: 365, label: "1y" }, +] + +export function GroupsTab() { + const [days, setDays] = useState(90) + const membership = useGroupMembership() + const funnel = useJoinRequestFunnel(days) + + const groups = (membership.data ?? []).filter((g) => g.member_count > 0) + const f = funnel.data + + return ( +
+ + + + + + + } /> + {value}} + /> + + + + + + + +
+ {funnel.isLoading ? ( + Array.from({ length: 4 }).map((_, i) => ) + ) : ( + <> + + + + + + )} +
+ + } + isLoading={funnel.isLoading} + isEmpty={!f || (f.pending === 0 && f.approved === 0 && f.rejected === 0)} + height={260} + > + + + + + + } /> + + + + +
+ ) +} diff --git a/web/src/pages/analytics/tabs/MembersTab.tsx b/web/src/pages/analytics/tabs/MembersTab.tsx new file mode 100644 index 0000000..7877530 --- /dev/null +++ b/web/src/pages/analytics/tabs/MembersTab.tsx @@ -0,0 +1,211 @@ +import { useState } from "react" +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ComposedChart, + Legend, + Line, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts" + +import { ChartCard, ChartTooltip, RangeToggle } from "@/components/analytics/primitives" +import { AXIS_COLOR, GRID_COLOR, PALETTE } from "@/components/analytics/utils" +import { + useAuthMethods, + useMemberDemographics, + useUserGrowth, + type CategoryCount, +} from "@/lib/analytics" + +const MONTH_RANGES: Array<{ value: number; label: string }> = [ + { value: 6, label: "6m" }, + { value: 12, label: "12m" }, + { value: 24, label: "24m" }, +] + +function monthLabel(value: string | number) { + const [y, m] = String(value).split("-") + const d = new Date(Date.UTC(Number(y), Number(m) - 1, 1)) + return `${d.toLocaleString("en-US", { month: "short", timeZone: "UTC" })} '${String(y).slice(2)}` +} + +function CategoryBar({ + data, + color, +}: { + data: CategoryCount[] + color: string +}) { + return ( + + + + 6 ? -30 : 0} + textAnchor={data.length > 6 ? "end" : "middle"} + height={data.length > 6 ? 60 : 30} + /> + + } /> + + + + ) +} + +function Donut({ data }: { data: CategoryCount[] }) { + return ( + + + + {data.map((_, i) => ( + + ))} + + } /> + {value}} + /> + + + ) +} + +export function MembersTab() { + const [months, setMonths] = useState(12) + const growth = useUserGrowth(months) + const demographics = useMemberDemographics() + const authMethods = useAuthMethods() + + const authData: CategoryCount[] = authMethods.data + ? [ + { label: "Email", count: authMethods.data.email }, + { label: "Phone", count: authMethods.data.phone }, + { label: "Discord", count: authMethods.data.discord }, + { label: "Google", count: authMethods.data.google }, + { label: "GitHub", count: authMethods.data.github }, + ].filter((m) => m.count > 0) + : [] + + const gradYear = demographics.data?.by_grad_year ?? [] + const major = demographics.data?.by_major ?? [] + const gradLevel = demographics.data?.by_graduate_level ?? [] + + return ( +
+ } + isLoading={growth.isLoading} + isEmpty={(growth.data ?? []).every((p) => p.new_users === 0 && p.cumulative === 0)} + height={320} + > + + + + + + } /> + {value}} + /> + + + + + + +
+ + + + + + +
+ +
+ + + + + + +
+
+ ) +} diff --git a/web/src/pages/analytics/tabs/OverviewTab.tsx b/web/src/pages/analytics/tabs/OverviewTab.tsx new file mode 100644 index 0000000..5667302 --- /dev/null +++ b/web/src/pages/analytics/tabs/OverviewTab.tsx @@ -0,0 +1,182 @@ +import { Activity, Boxes, KeyRound, LogIn, UserPlus, Users, UsersRound } from "lucide-react" +import { Link } from "react-router-dom" +import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts" + +import { ChartCard, ChartTooltip, StatCard, StatCardSkeleton } from "@/components/analytics/primitives" +import { AXIS_COLOR, formatNumber, GRID_COLOR, PALETTE } from "@/components/analytics/utils" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { + useAnalyticsOverview, + useJoinRequestFunnel, + useLoginTimeSeries, + useTopApplications, +} from "@/lib/analytics" + +function shortDate(value: string | number) { + const d = new Date(`${value}T00:00:00Z`) + return `${d.getUTCMonth() + 1}/${d.getUTCDate()}` +} + +export function OverviewTab() { + const overview = useAnalyticsOverview() + const series = useLoginTimeSeries(30) + const funnel = useJoinRequestFunnel(90) + const topApps = useTopApplications(30, 5) + + const o = overview.data + + const stats = [ + { label: "Members", value: o?.total_users, sub: `+${o?.new_users_30d ?? 0} in 30d`, icon: Users }, + { label: "Active (30d)", value: o?.active_users_30d, sub: `${o?.active_users_7d ?? 0} in last 7d`, icon: Activity }, + { label: "Sign-ins (7d)", value: o?.logins_7d, sub: `${o?.logins_24h ?? 0} in last 24h`, icon: LogIn }, + { label: "New members (30d)", value: o?.new_users_30d, icon: UserPlus }, + { label: "Applications", value: o?.total_applications, icon: Boxes }, + { label: "Groups", value: o?.total_groups, icon: UsersRound }, + { label: "Service accounts", value: o?.total_service_accounts, icon: KeyRound }, + { label: "Pending requests", value: o?.pending_join_requests, sub: `${o?.audit_events_7d ?? 0} audit events 7d`, icon: Users }, + ] + + return ( +
+
+ {overview.isLoading + ? Array.from({ length: 8 }).map((_, i) => ) + : stats.map((s) => ( + + ))} +
+ + p.logins === 0)} + > + + + + + + + + + + + + } /> + + + + + + +
+ + + Top applications + + + {topApps.isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : (topApps.data ?? []).length === 0 ? ( +

+ No sign-ins in the last 30 days. +

+ ) : ( +
    + {(topApps.data ?? []).map((app, i) => ( +
  • + {i + 1} + {app.name} + + {formatNumber(app.logins)} + +
  • + ))} +
+ )} +
+
+ + + + Join requests (90d) + + + {funnel.isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : ( +
+
+
Pending (now)
+
{funnel.data?.pending ?? 0}
+
+
+
Approved
+
{funnel.data?.approved ?? 0}
+
+
+
Rejected
+
{funnel.data?.rejected ?? 0}
+
+
+
Median time to decision
+
+ {funnel.data ? `${funnel.data.median_decision_hours.toFixed(1)}h` : "—"} +
+
+
+ )} +
+ + Manage groups → + +
+
+
+
+
+ ) +} diff --git a/web/src/pages/analytics/tabs/SigninsTab.tsx b/web/src/pages/analytics/tabs/SigninsTab.tsx new file mode 100644 index 0000000..b769113 --- /dev/null +++ b/web/src/pages/analytics/tabs/SigninsTab.tsx @@ -0,0 +1,94 @@ +import { useState } from "react" +import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts" + +import { ChartCard, ChartTooltip, Heatmap, RangeToggle } from "@/components/analytics/primitives" +import { AXIS_COLOR, GRID_COLOR, PALETTE } from "@/components/analytics/utils" +import { useLoginHeatmap, useLoginTimeSeries } from "@/lib/analytics" + +function shortDate(value: string | number) { + const d = new Date(`${value}T00:00:00Z`) + return `${d.getUTCMonth() + 1}/${d.getUTCDate()}` +} + +const RANGES: Array<{ value: number; label: string }> = [ + { value: 7, label: "7d" }, + { value: 30, label: "30d" }, + { value: 90, label: "90d" }, +] + +export function SigninsTab() { + const [days, setDays] = useState(30) + const series = useLoginTimeSeries(days) + const heatmap = useLoginHeatmap(90) + + return ( +
+ } + isLoading={series.isLoading} + isEmpty={(series.data ?? []).every((p) => p.logins === 0)} + height={340} + > + + + + + + + + + + + + + + + + } /> + + + + + + + + + +
+ ) +}