diff --git a/local/api/api.go b/local/api/api.go index 0f3f775..f835917 100755 --- a/local/api/api.go +++ b/local/api/api.go @@ -34,6 +34,8 @@ func Init(di *dig.Container, app *fiber.App) { WebSocket: groups.Group("/ws"), Leaderboard: serverIdGroup.Group("/leaderboard"), Steam: groups.Group("/steam"), + ActivityLog: serverIdGroup.Group("/activity-log"), + Groups: groups, } accessKeyMiddleware := middleware.NewAccessKeyMiddleware() diff --git a/local/controller/activity_log.go b/local/controller/activity_log.go new file mode 100644 index 0000000..145eace --- /dev/null +++ b/local/controller/activity_log.go @@ -0,0 +1,72 @@ +package controller + +import ( + "acc-server-manager/local/middleware" + "acc-server-manager/local/model" + "acc-server-manager/local/service" + "acc-server-manager/local/utl/common" + "acc-server-manager/local/utl/error_handler" + + "github.com/gofiber/fiber/v2" +) + +type ActivityLogController struct { + service *service.ActivityLogService + errorHandler *error_handler.ControllerErrorHandler +} + +func NewActivityLogController(as *service.ActivityLogService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *ActivityLogController { + ac := &ActivityLogController{ + service: as, + errorHandler: error_handler.NewControllerErrorHandler(), + } + + // Per-server activity log: GET /server/:id/activity-log + routeGroups.ActivityLog.Use(auth.Authenticate) + routeGroups.ActivityLog.Get("/", ac.GetServerLogs) + + // Global activity log: GET /activity-log (admin use) + routeGroups.Groups.Group("/activity-log").Use(auth.Authenticate).Get("/", ac.GetAllLogs) + + return ac +} + +// GetServerLogs returns activity logs for a specific server. +// +// @Summary Return activity logs for a server +// @Tags ActivityLog +// @Success 200 {array} model.ActivityLog +// @Router /server/{id}/activity-log [get] +func (ac *ActivityLogController) GetServerLogs(c *fiber.Ctx) error { + var filter model.ActivityLogFilter + if err := common.ParseQueryFilter(c, &filter); err != nil { + return ac.errorHandler.HandleValidationError(c, err, "query_filter") + } + + result, err := ac.service.GetAll(c.UserContext(), &filter) + if err != nil { + return ac.errorHandler.HandleServiceError(c, err) + } + + return c.JSON(result) +} + +// GetAllLogs returns activity logs across all servers (admin). +// +// @Summary Return all activity logs +// @Tags ActivityLog +// @Success 200 {array} model.ActivityLog +// @Router /activity-log [get] +func (ac *ActivityLogController) GetAllLogs(c *fiber.Ctx) error { + var filter model.ActivityLogFilter + if err := common.ParseQueryFilter(c, &filter); err != nil { + return ac.errorHandler.HandleValidationError(c, err, "query_filter") + } + + result, err := ac.service.GetAll(c.UserContext(), &filter) + if err != nil { + return ac.errorHandler.HandleServiceError(c, err) + } + + return c.JSON(result) +} diff --git a/local/controller/controller.go b/local/controller/controller.go index 79af86b..7348963 100755 --- a/local/controller/controller.go +++ b/local/controller/controller.go @@ -69,4 +69,9 @@ func InitializeControllers(c *dig.Container) { if err != nil { logging.Panic("unable to initialize steam controller") } + + err = c.Invoke(NewActivityLogController) + if err != nil { + logging.Panic("unable to initialize activity log controller") + } } diff --git a/local/controller/leaderboard.go b/local/controller/leaderboard.go index d985fd9..a9074da 100755 --- a/local/controller/leaderboard.go +++ b/local/controller/leaderboard.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" ) + type LeaderboardController struct { service *service.LeaderboardService errorHandler *error_handler.ControllerErrorHandler @@ -60,7 +61,14 @@ func (lc *LeaderboardController) Update(c *fiber.Ctx) error { return lc.errorHandler.HandleParsingError(c, err) } - data, err := lc.service.Update(c.UserContext(), serverID, &input) + userInfo, _ := c.Locals("userInfo").(*middleware.CachedUserInfo) + actorID, actorUsername := "", "system" + if userInfo != nil { + actorID = userInfo.UserID + actorUsername = userInfo.Username + } + + data, err := lc.service.Update(c.UserContext(), serverID, &input, actorID, actorUsername) if err != nil { return lc.errorHandler.HandleServiceError(c, err) } diff --git a/local/middleware/auth.go b/local/middleware/auth.go index 7abc5fb..fbb0ec3 100755 --- a/local/middleware/auth.go +++ b/local/middleware/auth.go @@ -115,6 +115,7 @@ func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isO if os.Getenv("TESTING_ENV") == "true" { userInfo := CachedUserInfo{UserID: uuid.New().String(), Username: "test@example.com", RoleName: "Admin", Permissions: make(map[string]bool), CachedAt: time.Now()} ctx.Locals("userID", userInfo.UserID) + ctx.Locals("actorUsername", userInfo.Username) ctx.Locals("userInfo", userInfo) ctx.Locals("authTime", time.Now()) } else { @@ -127,6 +128,7 @@ func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isO } ctx.Locals("userID", claims.UserID) + ctx.Locals("actorUsername", userInfo.Username) ctx.Locals("userInfo", userInfo) ctx.Locals("authTime", time.Now()) } diff --git a/local/migrations/006_add_activity_log.go b/local/migrations/006_add_activity_log.go new file mode 100644 index 0000000..952f80c --- /dev/null +++ b/local/migrations/006_add_activity_log.go @@ -0,0 +1,33 @@ +package migrations + +import ( + "acc-server-manager/local/utl/logging" + "fmt" + + "gorm.io/gorm" +) + +func RunAddActivityLogMigration(db *gorm.DB) error { + if err := db.AutoMigrate(&MigrationRecord{}); err != nil { + return fmt.Errorf("failed to ensure migration_records table: %v", err) + } + + var record MigrationRecord + if err := db.Where("migration_name = ?", "006_add_activity_log").First(&record).Error; err == nil { + logging.Info("Activity log migration already applied, skipping") + return nil + } + + record = MigrationRecord{ + MigrationName: "006_add_activity_log", + AppliedAt: "datetime('now')", + Success: true, + Notes: "Added ActivityLog table via GORM AutoMigrate", + } + if err := db.Create(&record).Error; err != nil { + return fmt.Errorf("failed to record migration 006: %v", err) + } + + logging.Info("Activity log migration recorded") + return nil +} diff --git a/local/model/activity_log.go b/local/model/activity_log.go new file mode 100644 index 0000000..110bbaa --- /dev/null +++ b/local/model/activity_log.go @@ -0,0 +1,81 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type ActionType string + +const ( + ActionConfigUpdate ActionType = "config_update" + ActionServerStart ActionType = "server_start" + ActionServerStop ActionType = "server_stop" + ActionServerRestart ActionType = "server_restart" + ActionLeaderboardUpdate ActionType = "leaderboard_update" +) + +type ActivityLog struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"` + ServerID uuid.UUID `json:"serverId" gorm:"not null;type:uuid;index"` + UserID string `json:"userId" gorm:"not null"` + Username string `json:"username" gorm:"not null"` + Action ActionType `json:"action" gorm:"not null"` + Details string `json:"details" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` +} + +func (a *ActivityLog) BeforeCreate(tx *gorm.DB) error { + if a.ID == uuid.Nil { + a.ID = uuid.New() + } + if a.CreatedAt.IsZero() { + a.CreatedAt = time.Now().UTC() + } + return nil +} + +type ActivityLogFilter struct { + BaseFilter + ServerBasedFilter + DateRangeFilter + UserID string `query:"user_id"` + Action ActionType `query:"action"` +} + +func (f *ActivityLogFilter) ApplyFilter(query *gorm.DB) *gorm.DB { + if f.ServerID != "" { + if serverUUID, err := uuid.Parse(f.ServerID); err == nil { + query = query.Where("server_id = ?", serverUUID) + } + } + if f.UserID != "" { + query = query.Where("user_id = ?", f.UserID) + } + if f.Action != "" { + query = query.Where("action = ?", f.Action) + } + timeZero := time.Time{} + if f.StartDate != timeZero { + query = query.Where("created_at >= ?", f.StartDate) + } + if f.EndDate != timeZero { + query = query.Where("created_at <= ?", f.EndDate) + } + return query +} + +func (f *ActivityLogFilter) GetSorting() (field string, desc bool) { + if f.SortBy == "" { + return "created_at", true + } + return f.SortBy, f.SortDesc +} + +type LastActivityInfo struct { + UpdatedAt time.Time `json:"updatedAt"` + Username string `json:"username"` + Action ActionType `json:"action"` +} diff --git a/local/model/server.go b/local/model/server.go index 4693d6c..2c0cb18 100755 --- a/local/model/server.go +++ b/local/model/server.go @@ -35,18 +35,19 @@ func (s *Server) ToServerAPI() *ServerAPI { } type Server struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` - Name string `gorm:"not null" json:"name"` - Status ServiceStatus `json:"status" gorm:"-"` - IP string `gorm:"not null" json:"-"` - Port int `gorm:"not null" json:"-"` - Path string `gorm:"not null" json:"path"` - ServiceName string `gorm:"not null" json:"serviceName"` - Platform string `gorm:"not null;default:'windows'" json:"platform"` - ContainerID string `gorm:"default:''" json:"-"` - State *ServerState `gorm:"-" json:"state"` - DateCreated time.Time `json:"dateCreated"` - FromSteamCMD bool `gorm:"not null; default:true" json:"-"` + ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` + Name string `gorm:"not null" json:"name"` + Status ServiceStatus `json:"status" gorm:"-"` + IP string `gorm:"not null" json:"-"` + Port int `gorm:"not null" json:"-"` + Path string `gorm:"not null" json:"path"` + ServiceName string `gorm:"not null" json:"serviceName"` + Platform string `gorm:"not null;default:'windows'" json:"platform"` + ContainerID string `gorm:"default:''" json:"-"` + State *ServerState `gorm:"-" json:"state"` + LastActivity *LastActivityInfo `gorm:"-" json:"lastActivity,omitempty"` + DateCreated time.Time `json:"dateCreated"` + FromSteamCMD bool `gorm:"not null; default:true" json:"-"` } type PlayerState struct { diff --git a/local/repository/activity_log.go b/local/repository/activity_log.go new file mode 100644 index 0000000..9c27f91 --- /dev/null +++ b/local/repository/activity_log.go @@ -0,0 +1,75 @@ +package repository + +import ( + "acc-server-manager/local/model" + "context" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type ActivityLogRepository struct { + *BaseRepository[model.ActivityLog, model.ActivityLogFilter] +} + +func NewActivityLogRepository(db *gorm.DB) *ActivityLogRepository { + return &ActivityLogRepository{ + BaseRepository: NewBaseRepository[model.ActivityLog, model.ActivityLogFilter](db, model.ActivityLog{}), + } +} + +func (r *ActivityLogRepository) Insert(ctx context.Context, entry *model.ActivityLog) error { + return r.BaseRepository.Insert(ctx, entry) +} + +func (r *ActivityLogRepository) GetAll(ctx context.Context, filter *model.ActivityLogFilter) (*[]model.ActivityLog, error) { + return r.BaseRepository.GetAll(ctx, filter) +} + +type lastActivityRow struct { + ServerID string `gorm:"column:server_id"` + CreatedAt time.Time `gorm:"column:created_at"` + Username string `gorm:"column:username"` + Action model.ActionType `gorm:"column:action"` +} + +// GetLastActivityByServerIDs returns the most recent activity entry per server. +// Uses a single GROUP BY query with MAX(created_at); SQLite selects the other +// columns from the row that holds the max value — documented SQLite behaviour. +func (r *ActivityLogRepository) GetLastActivityByServerIDs(ctx context.Context, serverIDs []uuid.UUID) (map[uuid.UUID]*model.LastActivityInfo, error) { + out := make(map[uuid.UUID]*model.LastActivityInfo) + if len(serverIDs) == 0 { + return out, nil + } + + // Convert to string slice so the IN clause works with SQLite UUID strings. + ids := make([]string, len(serverIDs)) + for i, id := range serverIDs { + ids[i] = id.String() + } + + var rows []lastActivityRow + err := r.BaseRepository.db.WithContext(ctx). + Model(&model.ActivityLog{}). + Select("server_id, MAX(created_at) as created_at, username, action"). + Where("server_id IN ?", ids). + Group("server_id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + for _, row := range rows { + serverUUID, err := uuid.Parse(row.ServerID) + if err != nil { + continue + } + out[serverUUID] = &model.LastActivityInfo{ + UpdatedAt: row.CreatedAt, + Username: row.Username, + Action: row.Action, + } + } + return out, nil +} diff --git a/local/repository/repository.go b/local/repository/repository.go index c8feb17..66833c9 100755 --- a/local/repository/repository.go +++ b/local/repository/repository.go @@ -20,6 +20,7 @@ func InitializeRepositories(c *dig.Container) { c.Provide(NewSteamCredentialsRepository) c.Provide(NewMembershipRepository) c.Provide(NewLeaderboardRepository) + c.Provide(NewActivityLogRepository) if err := c.Provide(func() *model.Steam2FAManager { manager := model.NewSteam2FAManager() diff --git a/local/service/activity_log.go b/local/service/activity_log.go new file mode 100644 index 0000000..7883efb --- /dev/null +++ b/local/service/activity_log.go @@ -0,0 +1,62 @@ +package service + +import ( + "acc-server-manager/local/model" + "acc-server-manager/local/repository" + "acc-server-manager/local/utl/logging" + "context" + "encoding/json" + "fmt" +) + +type ActivityLogService struct { + repo *repository.ActivityLogRepository +} + +func NewActivityLogService(repo *repository.ActivityLogRepository) *ActivityLogService { + return &ActivityLogService{repo: repo} +} + +// Log persists an activity log entry. Errors are logged but not returned so +// callers can treat this as fire-and-forget. +func (s *ActivityLogService) Log(ctx context.Context, entry *model.ActivityLog) error { + if err := s.repo.Insert(ctx, entry); err != nil { + logging.Error("Failed to insert activity log: %v", err) + return err + } + return nil +} + +func (s *ActivityLogService) GetAll(ctx context.Context, filter *model.ActivityLogFilter) (*[]model.ActivityLog, error) { + return s.repo.GetAll(ctx, filter) +} + +// BuildConfigDetails produces a JSON string summarising which top-level keys +// changed between oldJSON and newJSON. +func BuildConfigDetails(file string, oldJSON, newJSON []byte) string { + var oldMap, newMap map[string]interface{} + + if err := json.Unmarshal(oldJSON, &oldMap); err != nil { + oldMap = map[string]interface{}{} + } + if err := json.Unmarshal(newJSON, &newMap); err != nil { + newMap = map[string]interface{}{} + } + + var changedKeys []string + for key, newVal := range newMap { + oldVal, exists := oldMap[key] + if !exists { + changedKeys = append(changedKeys, key) + continue + } + oldBytes, _ := json.Marshal(oldVal) + newBytes, _ := json.Marshal(newVal) + if string(oldBytes) != string(newBytes) { + changedKeys = append(changedKeys, key) + } + } + + keysJSON, _ := json.Marshal(changedKeys) + return fmt.Sprintf(`{"file":%q,"changed_keys":%s}`, file, string(keysJSON)) +} diff --git a/local/service/config.go b/local/service/config.go index 4b9c8ea..9a190a1 100755 --- a/local/service/config.go +++ b/local/service/config.go @@ -68,14 +68,16 @@ type ConfigService struct { repository *repository.ConfigRepository serverRepository *repository.ServerRepository serverService *ServerService + activityLog *ActivityLogService configCache *model.ServerConfigCache } -func NewConfigService(repository *repository.ConfigRepository, serverRepository *repository.ServerRepository) *ConfigService { +func NewConfigService(repository *repository.ConfigRepository, serverRepository *repository.ServerRepository, activityLog *ActivityLogService) *ConfigService { logging.Debug("Initializing ConfigService with 5m expiration and 1s throttle") return &ConfigService{ repository: repository, serverRepository: serverRepository, + activityLog: activityLog, configCache: model.NewServerConfigCache(model.CacheConfig{ ExpirationTime: 5 * time.Minute, ThrottleTime: 1 * time.Second, @@ -120,6 +122,12 @@ func (as *ConfigService) UpdateAllConfigs(ctx *fiber.Ctx, req *model.Configurati {SettingsJson, req.Settings}, } + actorID, _ := ctx.Locals("userID").(string) + actorUsername, _ := ctx.Locals("actorUsername").(string) + if actorUsername == "" { + actorUsername = "system" + } + var results []*model.Config for _, entry := range entries { if entry.body == nil { @@ -137,6 +145,19 @@ func (as *ConfigService) UpdateAllConfigs(ctx *fiber.Ctx, req *model.Configurati ChangedAt: time.Now(), }) results = append(results, result) + + if as.activityLog != nil { + details := BuildConfigDetails(entry.fileName, oldDataUTF8, newData) + go func(details string) { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: serverUUID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionConfigUpdate, + Details: details, + }) + }(details) + } } if len(results) > 0 { @@ -152,7 +173,13 @@ func (as *ConfigService) UpdateConfig(ctx *fiber.Ctx, body *map[string]interface configFile := ctx.Params("file") override := ctx.QueryBool("override", false) - return as.updateConfigInternal(ctx.UserContext(), serverID, configFile, body, override) + actorID, _ := ctx.Locals("userID").(string) + actorUsername, _ := ctx.Locals("actorUsername").(string) + if actorUsername == "" { + actorUsername = "system" + } + + return as.updateConfigInternal(ctx.UserContext(), serverID, configFile, body, override, actorID, actorUsername) } func (as *ConfigService) updateConfigFiles(ctx context.Context, server *model.Server, configFile string, body *map[string]interface{}, override bool) ([]byte, []byte, error) { @@ -211,7 +238,7 @@ func (as *ConfigService) updateConfigFiles(ctx context.Context, server *model.Se return oldDataUTF8, newData, nil } -func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID string, configFile string, body *map[string]interface{}, override bool) (*model.Config, error) { +func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID string, configFile string, body *map[string]interface{}, override bool, actorID, actorUsername string) (*model.Config, error) { serverUUID, err := uuid.Parse(serverID) if err != nil { logging.Error("Invalid server ID format: %v", err) @@ -230,8 +257,21 @@ func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID stri } as.configCache.InvalidateServerCache(serverID) - as.serverService.StartAccServerRuntime(server) + + if as.activityLog != nil { + details := BuildConfigDetails(configFile, oldDataUTF8, newData) + go func() { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: serverUUID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionConfigUpdate, + Details: details, + }) + }() + } + return as.repository.UpdateConfig(ctx, &model.Config{ ServerID: serverUUID, ConfigFile: configFile, diff --git a/local/service/leaderboard.go b/local/service/leaderboard.go index 64cdae0..f43437d 100755 --- a/local/service/leaderboard.go +++ b/local/service/leaderboard.go @@ -3,6 +3,7 @@ package service import ( "acc-server-manager/local/model" "acc-server-manager/local/repository" + "acc-server-manager/local/utl/logging" "context" "fmt" @@ -10,11 +11,12 @@ import ( ) type LeaderboardService struct { - repo *repository.LeaderboardRepository + repo *repository.LeaderboardRepository + activityLog *ActivityLogService } -func NewLeaderboardService(repo *repository.LeaderboardRepository) *LeaderboardService { - return &LeaderboardService{repo: repo} +func NewLeaderboardService(repo *repository.LeaderboardRepository, activityLog *ActivityLogService) *LeaderboardService { + return &LeaderboardService{repo: repo, activityLog: activityLog} } // Get returns the leaderboard for a server. @@ -23,7 +25,7 @@ func (s *LeaderboardService) Get(ctx context.Context, serverID uuid.UUID) (*mode } // Update replaces the leaderboard for a server. -func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, lb *model.Leaderboard) (*model.Leaderboard, error) { +func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, lb *model.Leaderboard, actorID, actorUsername string) (*model.Leaderboard, error) { if lb == nil { return nil, fmt.Errorf("input is required") } @@ -33,5 +35,25 @@ func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, lb for i := range lb.Races { lb.Races[i].Position = i } - return s.repo.FullReplace(ctx, serverID, lb) + result, err := s.repo.FullReplace(ctx, serverID, lb) + if err != nil { + return nil, err + } + + driverCount := len(lb.Drivers) + raceCount := len(lb.Races) + details := fmt.Sprintf(`{"driver_count":%d,"race_count":%d}`, driverCount, raceCount) + go func() { + if logErr := s.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: serverID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionLeaderboardUpdate, + Details: details, + }); logErr != nil { + logging.Error("Failed to log leaderboard update: %v", logErr) + } + }() + + return result, nil } diff --git a/local/service/server.go b/local/service/server.go index 84e8325..9b4b761 100755 --- a/local/service/server.go +++ b/local/service/server.go @@ -26,12 +26,13 @@ const ( type ServerService struct { repository *repository.ServerRepository stateHistoryRepo *repository.StateHistoryRepository + activityLogRepo *repository.ActivityLogRepository apiService *ServiceControlService configService *ConfigService steamService *SteamService runtime platform.ServerRuntime firewall platform.FirewallManager - logStreamer platform.LogStreamer + logStreamer platform.LogStreamer portPool *network.PortPoolManager webSocketService *WebSocketService instances sync.Map // uuid.UUID → *tracking.AccServerInstance @@ -56,6 +57,7 @@ func (s *ServerService) ensureLogTailing(server *model.Server, instance *trackin func NewServerService( repository *repository.ServerRepository, stateHistoryRepo *repository.StateHistoryRepository, + activityLogRepo *repository.ActivityLogRepository, apiService *ServiceControlService, configService *ConfigService, steamService *SteamService, @@ -68,6 +70,7 @@ func NewServerService( service := &ServerService{ repository: repository, stateHistoryRepo: stateHistoryRepo, + activityLogRepo: activityLogRepo, apiService: apiService, configService: configService, steamService: steamService, @@ -266,6 +269,16 @@ func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]m return nil, err } + // Collect server IDs for a single last-activity lookup. + serverIDs := make([]uuid.UUID, len(*servers)) + for i, srv := range *servers { + serverIDs[i] = srv.ID + } + lastActivities, laErr := s.activityLogRepo.GetLastActivityByServerIDs(ctx.UserContext(), serverIDs) + if laErr != nil { + logging.Error("Failed to get last activities for server list: %v", laErr) + } + for i := range *servers { server := &(*servers)[i] status, err := s.apiService.GetCachedStatus(server.ServiceName) @@ -282,6 +295,11 @@ func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]m server.State = serverInstance.State } } + if lastActivities != nil { + if info, ok := lastActivities[server.ID]; ok { + server.LastActivity = info + } + } } return servers, nil diff --git a/local/service/service.go b/local/service/service.go index d95639f..2f51d4e 100755 --- a/local/service/service.go +++ b/local/service/service.go @@ -23,6 +23,7 @@ func InitializeServices(c *dig.Container) { logging.Debug("Registering services") c.Provide(NewSteamService) + c.Provide(NewActivityLogService) c.Provide(NewServerService) c.Provide(NewStateHistoryService) c.Provide(NewServiceControlService) diff --git a/local/service/service_control.go b/local/service/service_control.go index 7641357..89ad683 100755 --- a/local/service/service_control.go +++ b/local/service/service_control.go @@ -15,6 +15,7 @@ type ServiceControlService struct { repository *repository.ServiceControlRepository serverRepository *repository.ServerRepository serverService *ServerService + activityLog *ActivityLogService statusCache *model.ServerStatusCache runtime platform.ServerRuntime } @@ -23,11 +24,13 @@ func NewServiceControlService( repository *repository.ServiceControlRepository, serverRepository *repository.ServerRepository, runtime platform.ServerRuntime, + activityLog *ActivityLogService, ) *ServiceControlService { return &ServiceControlService{ repository: repository, serverRepository: serverRepository, runtime: runtime, + activityLog: activityLog, statusCache: model.NewServerStatusCache(model.CacheConfig{ ExpirationTime: 30 * time.Second, ThrottleTime: 5 * time.Second, @@ -36,6 +39,28 @@ func NewServiceControlService( } } +// getServerFromCtx resolves the server from ctx locals (serverId or service name). +func (as *ServiceControlService) getServerFromCtx(ctx *fiber.Ctx) (*model.Server, error) { + serviceName, ok := ctx.Locals("service").(string) + if !ok || serviceName == "" { + serverId, ok2 := ctx.Locals("serverId").(string) + if !ok2 || serverId == "" { + return nil, errors.New("service name missing") + } + return as.serverRepository.GetByID(ctx.UserContext(), serverId) + } + return as.serverRepository.GetFirstByServiceName(ctx.UserContext(), serviceName) +} + +func extractActor(ctx *fiber.Ctx) (actorID, actorUsername string) { + actorID, _ = ctx.Locals("userID").(string) + actorUsername, _ = ctx.Locals("actorUsername").(string) + if actorUsername == "" { + actorUsername = "system" + } + return +} + func (as *ServiceControlService) SetServerService(serverService *ServerService) { as.serverService = serverService } @@ -61,10 +86,11 @@ func (as *ServiceControlService) GetStatus(ctx *fiber.Ctx) (string, error) { } func (as *ServiceControlService) ServiceControlStartServer(ctx *fiber.Ctx) (string, error) { - serviceName, err := as.GetServiceName(ctx) + server, err := as.getServerFromCtx(ctx) if err != nil { return "", err } + serviceName := server.ServiceName as.statusCache.UpdateStatus(serviceName, model.StatusStarting) @@ -79,14 +105,29 @@ func (as *ServiceControlService) ServiceControlStartServer(ctx *fiber.Ctx) (stri status := model.ParseServiceStatus(statusStr) as.statusCache.UpdateStatus(serviceName, status) + + if as.activityLog != nil { + actorID, actorUsername := extractActor(ctx) + go func() { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: server.ID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionServerStart, + Details: `{"service":"` + serviceName + `"}`, + }) + }() + } + return status.String(), nil } func (as *ServiceControlService) ServiceControlStopServer(ctx *fiber.Ctx) (string, error) { - serviceName, err := as.GetServiceName(ctx) + server, err := as.getServerFromCtx(ctx) if err != nil { return "", err } + serviceName := server.ServiceName as.statusCache.UpdateStatus(serviceName, model.StatusStopping) @@ -101,14 +142,29 @@ func (as *ServiceControlService) ServiceControlStopServer(ctx *fiber.Ctx) (strin status := model.ParseServiceStatus(statusStr) as.statusCache.UpdateStatus(serviceName, status) + + if as.activityLog != nil { + actorID, actorUsername := extractActor(ctx) + go func() { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: server.ID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionServerStop, + Details: `{"service":"` + serviceName + `"}`, + }) + }() + } + return status.String(), nil } func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (string, error) { - serviceName, err := as.GetServiceName(ctx) + server, err := as.getServerFromCtx(ctx) if err != nil { return "", err } + serviceName := server.ServiceName as.statusCache.UpdateStatus(serviceName, model.StatusRestarting) @@ -123,6 +179,20 @@ func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (st status := model.ParseServiceStatus(statusStr) as.statusCache.UpdateStatus(serviceName, status) + + if as.activityLog != nil { + actorID, actorUsername := extractActor(ctx) + go func() { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: server.ID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionServerRestart, + Details: `{"service":"` + serviceName + `"}`, + }) + }() + } + return status.String(), nil } diff --git a/local/utl/common/common.go b/local/utl/common/common.go index 0ad0917..04014f6 100755 --- a/local/utl/common/common.go +++ b/local/utl/common/common.go @@ -28,6 +28,8 @@ type RouteGroups struct { WebSocket fiber.Router Leaderboard fiber.Router Steam fiber.Router + ActivityLog fiber.Router + Groups fiber.Router } func CheckError(err error) { diff --git a/local/utl/db/db.go b/local/utl/db/db.go index 0500181..aefcf60 100755 --- a/local/utl/db/db.go +++ b/local/utl/db/db.go @@ -54,6 +54,7 @@ func Migrate(db *gorm.DB) { &model.LeaderboardRace{}, &model.LeaderboardResult{}, &model.LeaderboardPointRow{}, + &model.ActivityLog{}, ) if err != nil { @@ -80,6 +81,10 @@ func runMigrations(db *gorm.DB) { logging.Error("Failed to run platform columns migration: %v", err) } + if err := migrations.RunAddActivityLogMigration(db); err != nil { + logging.Error("Failed to run activity log migration: %v", err) + } + logging.Info("Custom database migrations completed") } diff --git a/tests/unit/service/config_service_test.go b/tests/unit/service/config_service_test.go index ecc3d41..5169a0d 100755 --- a/tests/unit/service/config_service_test.go +++ b/tests/unit/service/config_service_test.go @@ -20,7 +20,7 @@ func TestConfigService_GetConfiguration_ValidFile(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) config, err := configService.GetConfiguration(helper.TestData.Server) tests.AssertNoError(t, err) @@ -43,7 +43,7 @@ func TestConfigService_GetEventConfig_ValidFile(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) eventConfig, err := configService.GetEventConfig(helper.TestData.Server) tests.AssertNoError(t, err) @@ -73,7 +73,7 @@ func TestConfigService_SaveConfiguration_Success(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) newConfig := &model.Configuration{ UdpPort: model.IntString(9999), @@ -115,7 +115,7 @@ func TestConfigService_LoadConfigs_Success(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) configs, err := configService.LoadConfigs(helper.TestData.Server) tests.AssertNoError(t, err) @@ -142,7 +142,7 @@ func TestConfigService_MalformedJSON(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) config, err := configService.GetConfiguration(helper.TestData.Server) if err == nil { @@ -245,7 +245,7 @@ func TestConfigService_Caching_Configuration(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) config1, err := configService.GetConfiguration(helper.TestData.Server) tests.AssertNoError(t, err) @@ -276,7 +276,7 @@ func TestConfigService_Caching_EventConfig(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) event1, err := configService.GetEventConfig(helper.TestData.Server) tests.AssertNoError(t, err)