From e637b5e6490a8f3e7cd99554a9440a9de7549854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Sun, 22 Mar 2026 13:38:00 +0100 Subject: [PATCH] feat: add server log endpoint and streaming --- local/api/api.go | 1 + local/controller/controller.go | 5 +++ local/controller/log.go | 62 ++++++++++++++++++++++++++++ local/model/websocket.go | 5 +++ local/platform/interfaces.go | 1 + local/service/docker_log_streamer.go | 32 ++++++++++++++ local/service/file_log_streamer.go | 30 ++++++++++++++ local/service/server.go | 17 +++++++- local/service/websocket.go | 10 +++++ local/utl/common/common.go | 1 + 10 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 local/controller/log.go diff --git a/local/api/api.go b/local/api/api.go index 0f3f775..a2487d2 100755 --- a/local/api/api.go +++ b/local/api/api.go @@ -34,6 +34,7 @@ func Init(di *dig.Container, app *fiber.App) { WebSocket: groups.Group("/ws"), Leaderboard: serverIdGroup.Group("/leaderboard"), Steam: groups.Group("/steam"), + Log: serverIdGroup.Group("/log"), } accessKeyMiddleware := middleware.NewAccessKeyMiddleware() diff --git a/local/controller/controller.go b/local/controller/controller.go index 79af86b..b304313 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(NewLogController) + if err != nil { + logging.Panic("unable to initialize log controller") + } } diff --git a/local/controller/log.go b/local/controller/log.go new file mode 100644 index 0000000..9d27487 --- /dev/null +++ b/local/controller/log.go @@ -0,0 +1,62 @@ +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" + "strconv" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +type LogController struct { + service *service.ServerService + errorHandler *error_handler.ControllerErrorHandler +} + +func NewLogController(ss *service.ServerService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *LogController { + lc := &LogController{ + service: ss, + errorHandler: error_handler.NewControllerErrorHandler(), + } + + logRoutes := routeGroups.Log + logRoutes.Use(auth.Authenticate) + logRoutes.Get("/", auth.HasPermission(model.ServerView), lc.GetLogLines) + + return lc +} + +// GetLogLines godoc +// @Summary Get last N lines of server log +// @Tags Server +// @Param id path string true "Server ID" +// @Param lines query int false "Number of lines to return (default 100)" +// @Success 200 {object} object{lines=[]string} +// @Security BearerAuth +// @Router /server/{id}/log [get] +func (lc *LogController) GetLogLines(c *fiber.Ctx) error { + serverIDStr := c.Params("id") + serverID, err := uuid.Parse(serverIDStr) + if err != nil { + return lc.errorHandler.HandleUUIDError(c, "server ID") + } + + n, _ := strconv.Atoi(c.Query("lines", "100")) + if n <= 0 { + n = 100 + } + if n > 10000 { + n = 10000 + } + + lines, err := lc.service.GetLastLogLines(c, serverID, n) + if err != nil { + return lc.errorHandler.HandleServiceError(c, err) + } + + return c.JSON(fiber.Map{"lines": lines}) +} diff --git a/local/model/websocket.go b/local/model/websocket.go index f894a9e..6db5a26 100755 --- a/local/model/websocket.go +++ b/local/model/websocket.go @@ -33,6 +33,7 @@ const ( MessageTypeSteamOutput WebSocketMessageType = "steam_output" MessageTypeError WebSocketMessageType = "error" MessageTypeComplete WebSocketMessageType = "complete" + MessageTypeLogLine WebSocketMessageType = "log_line" ) type WebSocketMessage struct { @@ -65,6 +66,10 @@ type CompleteMessage struct { Message string `json:"message"` } +type LogLineMessage struct { + Line string `json:"line"` +} + func GetStepDescription(step ServerCreationStep) string { descriptions := map[ServerCreationStep]string{ StepValidation: "Validating server configuration", diff --git a/local/platform/interfaces.go b/local/platform/interfaces.go index 47de4ed..d292260 100644 --- a/local/platform/interfaces.go +++ b/local/platform/interfaces.go @@ -25,4 +25,5 @@ type FirewallManager interface { type LogStreamer interface { Start(ctx context.Context, server *model.Server, handleLine func(string)) error Stop(serverID uuid.UUID) + GetLastLines(ctx context.Context, server *model.Server, n int) ([]string, error) } diff --git a/local/service/docker_log_streamer.go b/local/service/docker_log_streamer.go index e84ad40..347ef8b 100644 --- a/local/service/docker_log_streamer.go +++ b/local/service/docker_log_streamer.go @@ -6,6 +6,7 @@ import ( "bufio" "context" "io" + "strconv" "sync" "github.com/docker/docker/api/types" @@ -72,3 +73,34 @@ func (s *DockerLogStreamer) Stop(serverID uuid.UUID) { v.(context.CancelFunc)() } } + +func (s *DockerLogStreamer) GetLastLines(ctx context.Context, server *model.Server, n int) ([]string, error) { + if n <= 0 || server.ContainerID == "" { + return []string{}, nil + } + + rc, err := s.client.ContainerLogs(ctx, server.ContainerID, types.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Tail: strconv.Itoa(n), + }) + if err != nil { + return nil, err + } + defer rc.Close() + + pr, pw := io.Pipe() + go func() { + defer pw.Close() + if _, err := stdcopy.StdCopy(pw, pw, rc); err != nil && err != io.EOF { + logging.Warn("DockerLogStreamer.GetLastLines: stdcopy error for %s: %v", server.ServiceName, err) + } + }() + + var lines []string + scanner := bufio.NewScanner(pr) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines, scanner.Err() +} diff --git a/local/service/file_log_streamer.go b/local/service/file_log_streamer.go index 62bd3e6..2474565 100644 --- a/local/service/file_log_streamer.go +++ b/local/service/file_log_streamer.go @@ -3,7 +3,9 @@ package service import ( "acc-server-manager/local/model" "acc-server-manager/local/utl/tracking" + "bufio" "context" + "os" "path/filepath" "sync" @@ -36,3 +38,31 @@ func (s *FileLogStreamer) Stop(serverID uuid.UUID) { v.(*tracking.LogTailer).Stop() } } + +func (s *FileLogStreamer) GetLastLines(_ context.Context, server *model.Server, n int) ([]string, error) { + if n <= 0 { + return []string{}, nil + } + logPath := filepath.Join(server.GetLogPath(), "server.log") + f, err := os.Open(logPath) + if err != nil { + if os.IsNotExist(err) { + return []string{}, nil + } + return nil, err + } + defer f.Close() + + var lines []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return nil, err + } + if len(lines) <= n { + return lines, nil + } + return lines[len(lines)-n:], nil +} diff --git a/local/service/server.go b/local/service/server.go index 84e8325..84d83a7 100755 --- a/local/service/server.go +++ b/local/service/server.go @@ -47,12 +47,27 @@ type pendingState struct { func (s *ServerService) ensureLogTailing(server *model.Server, instance *tracking.AccServerInstance) { go func() { - if err := s.logStreamer.Start(context.Background(), server, instance.HandleLogLine); err != nil { + handleFn := func(line string) { + instance.HandleLogLine(line) + s.webSocketService.BroadcastLogLine(server.ID, line) + } + if err := s.logStreamer.Start(context.Background(), server, handleFn); err != nil { logging.Error("Failed to start log streaming for server %s: %v", server.ID, err) } }() } +func (s *ServerService) GetLastLogLines(ctx *fiber.Ctx, serverID uuid.UUID, n int) ([]string, error) { + server, err := s.repository.GetByID(ctx.UserContext(), serverID) + if err != nil { + return nil, err + } + if n <= 0 { + n = 100 + } + return s.logStreamer.GetLastLines(ctx.UserContext(), server, n) +} + func NewServerService( repository *repository.ServerRepository, stateHistoryRepo *repository.StateHistoryRepository, diff --git a/local/service/websocket.go b/local/service/websocket.go index 1f2cbd2..6394050 100755 --- a/local/service/websocket.go +++ b/local/service/websocket.go @@ -103,6 +103,16 @@ func (ws *WebSocketService) BroadcastError(serverID uuid.UUID, error string, det ws.broadcastToServer(serverID, wsMsg) } +func (ws *WebSocketService) BroadcastLogLine(serverID uuid.UUID, line string) { + msg := model.WebSocketMessage{ + Type: model.MessageTypeLogLine, + ServerID: &serverID, + Timestamp: time.Now().Unix(), + Data: model.LogLineMessage{Line: line}, + } + ws.broadcastToServer(serverID, msg) +} + func (ws *WebSocketService) BroadcastComplete(serverID uuid.UUID, success bool, message string) { completeMsg := model.CompleteMessage{ ServerID: serverID, diff --git a/local/utl/common/common.go b/local/utl/common/common.go index 0ad0917..4f9ca07 100755 --- a/local/utl/common/common.go +++ b/local/utl/common/common.go @@ -28,6 +28,7 @@ type RouteGroups struct { WebSocket fiber.Router Leaderboard fiber.Router Steam fiber.Router + Log fiber.Router } func CheckError(err error) {