feat: add activity logs

This commit is contained in:
2026-03-22 13:52:15 +01:00
parent 85c5874bc7
commit e96b8bca12
17 changed files with 517 additions and 26 deletions

View File

@@ -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))
}

View File

@@ -1,6 +1,7 @@
package service
import (
"acc-server-manager/local/middleware"
"acc-server-manager/local/model"
"acc-server-manager/local/repository"
"acc-server-manager/local/utl/common"
@@ -68,14 +69,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 +123,13 @@ func (as *ConfigService) UpdateAllConfigs(ctx *fiber.Ctx, req *model.Configurati
{SettingsJson, req.Settings},
}
userInfo, _ := ctx.Locals("userInfo").(*middleware.CachedUserInfo)
actorID, actorUsername := "", "system"
if userInfo != nil {
actorID = userInfo.UserID
actorUsername = userInfo.Username
}
var results []*model.Config
for _, entry := range entries {
if entry.body == nil {
@@ -137,6 +147,17 @@ func (as *ConfigService) UpdateAllConfigs(ctx *fiber.Ctx, req *model.Configurati
ChangedAt: time.Now(),
})
results = append(results, result)
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,14 @@ 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)
userInfo, _ := ctx.Locals("userInfo").(*middleware.CachedUserInfo)
actorID, actorUsername := "", "system"
if userInfo != nil {
actorID = userInfo.UserID
actorUsername = userInfo.Username
}
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 +239,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 +258,19 @@ func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID stri
}
as.configCache.InvalidateServerCache(serverID)
as.serverService.StartAccServerRuntime(server)
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,

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)

View File

@@ -1,6 +1,7 @@
package service
import (
"acc-server-manager/local/middleware"
"acc-server-manager/local/model"
"acc-server-manager/local/platform"
"acc-server-manager/local/repository"
@@ -15,6 +16,7 @@ type ServiceControlService struct {
repository *repository.ServiceControlRepository
serverRepository *repository.ServerRepository
serverService *ServerService
activityLog *ActivityLogService
statusCache *model.ServerStatusCache
runtime platform.ServerRuntime
}
@@ -23,11 +25,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 +40,27 @@ 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) {
userInfo, _ := ctx.Locals("userInfo").(*middleware.CachedUserInfo)
if userInfo != nil {
return userInfo.UserID, userInfo.Username
}
return "", "system"
}
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,27 @@ func (as *ServiceControlService) ServiceControlStartServer(ctx *fiber.Ctx) (stri
status := model.ParseServiceStatus(statusStr)
as.statusCache.UpdateStatus(serviceName, status)
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 +140,27 @@ func (as *ServiceControlService) ServiceControlStopServer(ctx *fiber.Ctx) (strin
status := model.ParseServiceStatus(statusStr)
as.statusCache.UpdateStatus(serviceName, status)
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 +175,18 @@ func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (st
status := model.ParseServiceStatus(statusStr)
as.statusCache.UpdateStatus(serviceName, status)
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
}