Skip to content

Commit

Permalink
feat: add layer 0 operational insights page w/ charts for user data
Browse files Browse the repository at this point in the history
  • Loading branch information
PThorpe92 committed Dec 6, 2024
1 parent 8a68502 commit 72c9fe0
Show file tree
Hide file tree
Showing 23 changed files with 584 additions and 32 deletions.
2 changes: 1 addition & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ require (
github.com/stretchr/testify v1.9.0
github.com/teambition/rrule-go v1.8.2
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8
golang.org/x/oauth2 v0.24.0
gorm.io/datatypes v1.2.0
gorm.io/driver/postgres v1.5.9
gorm.io/gorm v1.25.11
Expand Down Expand Up @@ -74,7 +75,6 @@ require (
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.26.0 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
Expand Down
28 changes: 28 additions & 0 deletions backend/migrations/00025_add_login_count_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE public.login_metrics (
user_id INTEGER NOT NULL PRIMARY KEY,
total BIGINT NOT NULL DEFAULT 1,
last_login timestamp with time zone DEFAULT now(),
FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX user_login_metrics_user_id_idx ON public.login_metrics(user_id);
CREATE INDEX user_login_metrics_login_count_idx ON public.login_metrics(total);
CREATE INDEX user_login_metrics_last_login_idx ON public.login_metrics(last_login);

CREATE TABLE public.login_activity (
time_interval TIMESTAMP NOT NULL,
facility_id INT,
total_logins BIGINT DEFAULT 1,
FOREIGN KEY (facility_id) REFERENCES public.facilities(id) ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY (time_interval, facility_id)
);
CREATE INDEX login_activity_time_interval_idx ON public.login_activity(time_interval);
CREATE INDEX login_activity_facility_id_idx ON public.login_activity(facility_id);
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
DROP TABLE public.login_metrics CASCADE;
DROP TABLE public.login_activity CASCADE;
-- +goose StatementEnd
105 changes: 105 additions & 0 deletions backend/src/database/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"UnlockEdv2/src/models"
"errors"
"strings"
"time"

log "github.com/sirupsen/logrus"
"gorm.io/gorm"
Expand Down Expand Up @@ -185,3 +186,107 @@ func (db *DB) ToggleProgramFavorite(user_id uint, id uint) (bool, error) {
}
return favRemoved, nil
}

func (db *DB) IncrementUserLogin(username string) error {
log.Printf("Incrementing login count for %s", username)
user, err := db.GetUserByUsername(username)
if err != nil {
log.Errorf("Error getting user by username: %v", err)
return newGetRecordsDBError(err, "users")
}
if err := db.Debug().Exec(
`INSERT INTO login_metrics (user_id, total, last_login)
VALUES (?, 1, CURRENT_TIMESTAMP)
ON CONFLICT (user_id) DO UPDATE
SET total = login_metrics.total + 1, last_login = CURRENT_TIMESTAMP`,
user.ID).Error; err != nil {
log.Errorf("Error incrementing login count: %v", err)
return newUpdateDBError(err, "login_metrics")
}
now := time.Now()
rounded := now.Truncate(time.Hour)

if err := db.Debug().Exec(
`INSERT INTO login_activity (time_interval, facility_id, total_logins)
VALUES (?, ?, ?)
ON CONFLICT (time_interval, facility_id)
DO UPDATE SET total_logins = login_activity.total_logins + 1`,
rounded, user.FacilityID, 1).Error; err != nil {
log.Errorf("Error incrementing login activity: %v", err)
return newUpdateDBError(err, "login_activity")
}

log.Printf("FINISHED Incremented login count for %s", username)
return nil
}

func (db *DB) GetNumberOfActiveUsersForTimePeriod(active bool, days int, facilityId *uint) (int64, error) {
var count int64
daysAgo := time.Now().AddDate(0, 0, -days)
join := "JOIN login_metrics on users.id = login_metrics.user_id AND login_metrics.last_login "
if active {
join += "> ?"
} else {
join += "< ?"
}
tx := db.Model(&models.User{}).Joins(join, daysAgo).Where("role = 'student'")
if facilityId != nil {
tx = tx.Where("facility_id = ?", *facilityId)
}
if err := tx.Count(&count).Error; err != nil {
return 0, newGetRecordsDBError(err, "users")
}
return count, nil
}

func (db *DB) NewUsersInTimePeriod(days int, facilityId *uint) (int64, error) {
var count int64
daysAgo := time.Now().AddDate(0, 0, -days)
tx := db.Model(&models.User{}).Where("created_at >= ? AND role NOT IN ('system_admin', 'admin')", daysAgo)
if facilityId != nil {
tx = tx.Where("facility_id = ?", *facilityId)
}
if err := tx.Count(&count).Error; err != nil {
return 0, newGetRecordsDBError(err, "users")
}
return count, nil
}

func (db *DB) GetTotalLogins(days int, facilityId *uint) (int64, error) {
var total int64
daysAgo := time.Now().AddDate(0, 0, -days)
tx := db.Model(&models.LoginActivity{}).Select("SUM(total_logins)").Where("time_interval >= ?", daysAgo)
if facilityId != nil {
tx = tx.Where("facility_id = ?", *facilityId)
}
if err := tx.Scan(&total).Error; err != nil {
return 0, newGetRecordsDBError(err, "login_activity")
}
return total, nil
}

func (db *DB) GetTotalUsers(facilityId *uint) (int64, error) {
var total int64
tx := db.Model(&models.User{}).Where("role NOT IN ('admin', 'system_admin')")
if facilityId != nil {
tx = tx.Where("facility_id = ?", *facilityId)
}
if err := tx.Count(&total).Error; err != nil {
return 0, newGetRecordsDBError(err, "users")
}
return total, nil
}

func (db *DB) GetLoginActivity(days int, facilityID *uint) ([]models.LoginActivity, error) {
acitvity := make([]models.LoginActivity, 0, 3)
daysAgo := time.Now().AddDate(0, 0, -days)
if err := db.Raw(`SELECT time_interval, total_logins
FROM login_activity
WHERE time_interval >= ?
ORDER BY total_logins DESC
LIMIT 3;`, daysAgo).
Scan(&acitvity).Error; err != nil {
return nil, newGetRecordsDBError(err, "login_activity")
}
return acitvity, nil
}
108 changes: 108 additions & 0 deletions backend/src/handlers/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ package handlers

import (
"UnlockEdv2/src/models"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"

"github.com/nats-io/nats.go"
)

func (srv *Server) registerDashboardRoutes() []routeDef {
axx := models.Feature(models.ProviderAccess)
return []routeDef{
{"GET /api/login-metrics", srv.handleLoginMetrics, true, models.Feature()},
{"GET /api/users/{id}/student-dashboard", srv.handleStudentDashboard, false, models.Feature()},
{"GET /api/users/{id}/admin-dashboard", srv.handleAdminDashboard, true, models.Feature()},
{"GET /api/users/{id}/catalogue", srv.handleUserCatalogue, false, axx},
Expand Down Expand Up @@ -42,6 +48,108 @@ func (srv *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request,
return writeJsonResponse(w, http.StatusOK, adminDashboard)
}

func (srv *Server) handleLoginMetrics(w http.ResponseWriter, r *http.Request, log sLog) error {
facility := r.URL.Query().Get("facility")
claims := r.Context().Value(ClaimsKey).(*Claims)
var facilityId *uint
facilityName := ""
clearCache := r.URL.Query().Get("reset") == "true"
switch facility {
case "all":
facilityId = nil
facilityName = "All"
case "":
facilityId = &claims.FacilityID
facility = strconv.Itoa(int(claims.FacilityID))
facilityName = claims.FacilityName
default:
facilityIdInt, err := strconv.Atoi(facility)
if err != nil {
return newInvalidIdServiceError(err, "facility")
}
ref := uint(facilityIdInt)
facilityId = &ref
facility, err := srv.Db.GetFacilityByID(facilityIdInt)
if err != nil {
return newDatabaseServiceError(err)
}
facilityName = facility.Name
}
var days int
daysQ := r.URL.Query().Get("days")
if daysQ == "" {
days = 30
} else {
numDays, err := strconv.Atoi(daysQ)
if err != nil {
return newInvalidIdServiceError(err, "days")
}
days = numDays
}
key := fmt.Sprintf("%s-%d", facility, days)
cached, err := srv.buckets[LoginMetrics].Get(key)
if err != nil && errors.Is(err, nats.ErrKeyNotFound) || clearCache {
acitveUsers, err := srv.Db.GetNumberOfActiveUsersForTimePeriod(true, days, facilityId)
if err != nil {
return newDatabaseServiceError(err)
}
totalLogins, err := srv.Db.GetTotalLogins(days, facilityId)
if err != nil {
return newDatabaseServiceError(err)
}
totalUsers, err := srv.Db.GetTotalUsers(facilityId)
if err != nil {
return newDatabaseServiceError(err)
}
newAdded, err := srv.Db.NewUsersInTimePeriod(days, facilityId)
if err != nil {
return newDatabaseServiceError(err)
}
loginTimes, err := srv.Db.GetLoginActivity(days, facilityId)
if err != nil {
return newDatabaseServiceError(err)
}
if totalUsers == 0 {
totalUsers = 1
}
activityMetrics := struct {
ActiveUsers int64 `json:"active_users"`
TotalLogins int64 `json:"total_logins"`
LoginsPerDay int64 `json:"logins_per_day"`
PercentActive int64 `json:"percent_active"`
PercentInactive int64 `json:"percent_inactive"`
TotalUsers int64 `json:"total_users"`
Facility string `json:"facility,omitempty"`
NewStudentsAdded int64 `json:"new_residents_added"`
PeakLoginTimes []models.LoginActivity `json:"peak_login_times"`
}{
ActiveUsers: acitveUsers,
TotalLogins: totalLogins,
LoginsPerDay: totalLogins / int64(days),
PercentActive: acitveUsers / totalUsers * 100,
PercentInactive: 100 - (acitveUsers / totalUsers * 100),
TotalUsers: totalUsers,
Facility: facilityName,
NewStudentsAdded: newAdded,
PeakLoginTimes: loginTimes,
}
jsonB, err := json.Marshal(models.DefaultResource(activityMetrics))
if err != nil {
return newMarshallingBodyServiceError(err)
}
_, err = srv.buckets[LoginMetrics].Put(key, jsonB)
if err != nil {
return newInternalServerServiceError(err, "Error caching login metrics")
}
_, err = w.Write(jsonB)
return err
} else if err != nil {
return newInternalServerServiceError(err, "Error retrieving login metrics")
}
_, err = w.Write(cached.Value())
return err
}

/**
* GET: /api/users/{id}/catalogue
* @Query Params:
Expand Down
1 change: 0 additions & 1 deletion backend/src/handlers/left_menu_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ func (srv *Server) registerLeftMenuRoutes() []routeDef {
}

func (srv *Server) handleGetLeftMenu(w http.ResponseWriter, r *http.Request, log sLog) error {
log.info("GET: /api/left-menu")
var limit int
limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil {
Expand Down
5 changes: 5 additions & 0 deletions backend/src/handlers/login_flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request, log sLog) e
if err != nil {
return NewServiceError(err, resp.StatusCode, "Invalid login")
}
err = s.Db.IncrementUserLogin(form.Username)
if err != nil {
log.error("Error incrementing user login count", err)
return newDatabaseServiceError(err)
}
return writeJsonResponse(w, http.StatusOK, redirect)
}

Expand Down
26 changes: 14 additions & 12 deletions backend/src/handlers/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ func newServer() *Server {
server.features = features
err = server.setupNatsKvBuckets()
if err != nil {
log.Errorf("Failed to setup JetStream KV store: %v", err)
log.Fatalf("Failed to setup JetStream KV store: %v", err)
}
server.initAwsConfig(ctx)
server.RegisterRoutes()
Expand Down Expand Up @@ -212,6 +212,7 @@ const (
CachedUsers string = "cache_users"
LibraryPaths string = "library_paths"
OAuthState string = "oauth_state"
LoginMetrics string = "login_metrics"
)

func (srv *Server) setupNatsKvBuckets() error {
Expand All @@ -221,21 +222,22 @@ func (srv *Server) setupNatsKvBuckets() error {
return err
}
buckets := map[string]nats.KeyValue{}
for _, bucket := range []string{CachedUsers, LibraryPaths, OAuthState} {
for _, bucket := range []string{CachedUsers, LibraryPaths, LoginMetrics, OAuthState} {
kv, err := js.KeyValue(bucket)
if err == nats.ErrBucketNotFound {
if err != nil {
cfg := &nats.KeyValueConfig{
Bucket: bucket,
History: 1,
}
switch bucket {
case CachedUsers:
cfg.TTL = time.Hour * 1
case LoginMetrics:
cfg.TTL = time.Hour * 24
case OAuthState:
kv, err = js.CreateKeyValue(&nats.KeyValueConfig{
Bucket: bucket,
History: 1,
TTL: 10 * time.Minute, //expire (time-to-live)
})
default:
kv, err = js.CreateKeyValue(&nats.KeyValueConfig{
Bucket: bucket,
})
cfg.TTL = time.Minute * 10
}
kv, err = js.CreateKeyValue(cfg)
if err != nil {
log.Errorf("Error creating JetStream KV store: %v", err)
return err
Expand Down
5 changes: 3 additions & 2 deletions backend/src/models/facilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ type Facility struct {
Name string `gorm:"size:255;not null" json:"name"`
Timezone string `gorm:"size:255;not null" json:"timezone" validate:"timezone"`

Users []User `gorm:"foreignKey:FacilityID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"-"`
Programs []Program `json:"programs" gorm:"many2many:facilities_programs;"`
Users []User `gorm:"foreignKey:FacilityID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"-"`
Programs []Program `json:"programs" gorm:"many2many:facilities_programs;"`
LoginActivity []LoginActivity `json:"login_activity" gorm:"foreignKey:FacilityID;constraint:OnDelete CASCADE"`
}

func (Facility) TableName() string {
Expand Down
23 changes: 23 additions & 0 deletions backend/src/models/logins.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package models

import "time"

type LoginMetrics struct {
UserID uint `json:"user_id" gorm:"primaryKey"`
Total int64 `json:"total" gorm:"default:1"`
LastLogin time.Time `json:"last_login" gorm:"default:CURRENT_TIMESTAMP"`

User *User `json:"user,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete CASCADE"`
}

func (LoginMetrics) TableName() string { return "login_metrics" }

type LoginActivity struct {
TimeInterval time.Time `json:"time_interval" gorm:"primaryKey"`
FacilityID uint `json:"facility_id" gorm:"primaryKey"`
TotalLogins int64 `json:"total_logins" gorm:"default:1"`

Facility *Facility `json:"facility,omitempty" gorm:"foreignKey:FacilityID;constraint:OnDelete CASCADE"`
}

func (LoginActivity) TableName() string { return "login_activity" }
Loading

0 comments on commit 72c9fe0

Please sign in to comment.