package api import ( "bufio" "containr/internal/database" "containr/internal/docker" "context" "fmt" "io" "net/http" "sort" "strings" "time" "github.com/docker/docker/api/types/filters" "github.com/gin-gonic/gin" "github.com/google/uuid" ) type LogEntry struct { Timestamp time.Time `json:"timestamp"` Message string `json:"message"` Stream string `json:"stream"` } func handleGetLogs(c *gin.Context) { db, exists := c.Get("db") if !exists { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"}) return } serviceIDStr := c.Param("id") serviceID, err := uuid.Parse(serviceIDStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"}) return } projectID, found := serviceProjectID(db.(*database.DB), serviceID) if !found { c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) return } if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed { c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) return } follow := c.DefaultQuery("follow", "false") == "true" tail := c.DefaultQuery("tail", "100") dockerClient, exists := c.Get("docker_client") if !exists || dockerClient == nil { respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.") return } client, ok := dockerClient.(*docker.Client) if !ok || client == nil { respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.") return } // Replicas are named containr--; resolve them by label and // merge logs across all of them. ctx := context.Background() containers, err := client.ListContainersFiltered(ctx, filters.NewArgs( filters.Arg("label", "containr.service="+serviceID.String()), ), false) if err != nil { respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.") return } if len(containers) == 0 { respondEmptyServiceLogs(c, "No container logs are available for this service yet.") return } sort.Slice(containers, func(i, j int) bool { return containers[i].Names[0] < containers[j].Names[0] }) logOpts := docker.LogOptions{ Stdout: true, Stderr: true, Follow: follow, Tail: tail, Timestamps: true, } var readers []io.ReadCloser var streams []io.Reader for _, ctr := range containers { r, rerr := client.GetContainerLogs(ctx, ctr.ID, logOpts) if rerr == nil { readers = append(readers, r) streams = append(streams, r) } } if len(readers) == 0 { respondEmptyServiceLogs(c, "No container logs are available for this service yet.") return } logsReader := io.MultiReader(streams...) defer func() { for _, r := range readers { _ = r.Close() } }() if follow { c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") streamWriter := c.Writer flusher, ok := streamWriter.(http.Flusher) if !ok { c.JSON(http.StatusInternalServerError, gin.H{"error": "Streaming not supported"}) return } scanner := bufio.NewScanner(logsReader) for scanner.Scan() { line := scanner.Text() if line == "" { continue } cleanLine := stripDockerLogHeader(line) entry := LogEntry{ Timestamp: time.Now(), Message: cleanLine, Stream: "stdout", } if strings.Contains(strings.ToLower(cleanLine), "error") || strings.Contains(strings.ToLower(cleanLine), "err") { entry.Stream = "stderr" } fmt.Fprintf(streamWriter, "data: {\"timestamp\":\"%s\",\"message\":\"%s\",\"stream\":\"%s\"}\n\n", entry.Timestamp.Format(time.RFC3339), strings.ReplaceAll(entry.Message, `"`, `\"`), entry.Stream, ) flusher.Flush() } return } logBytes, err := io.ReadAll(logsReader) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read logs"}) return } logContent := string(logBytes) var logEntries []LogEntry scanner := bufio.NewScanner(strings.NewReader(logContent)) for scanner.Scan() { line := scanner.Text() if line == "" { continue } cleanLine := stripDockerLogHeader(line) entry := LogEntry{ Timestamp: time.Now(), Message: cleanLine, Stream: "stdout", } if strings.Contains(strings.ToLower(cleanLine), "error") || strings.Contains(strings.ToLower(cleanLine), "err") { entry.Stream = "stderr" } logEntries = append(logEntries, entry) } c.JSON(http.StatusOK, gin.H{"logs": logEntries}) } func respondEmptyServiceLogs(c *gin.Context, message string) { c.JSON(http.StatusOK, gin.H{ "logs": []LogEntry{}, "message": message, }) } func handleGetDeploymentLogs(c *gin.Context) { db, exists := c.Get("db") if !exists { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"}) return } deploymentIDStr := c.Param("id") deploymentID, err := uuid.Parse(deploymentIDStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID"}) return } var buildLog, runtimeLog string var projectID uuid.UUID err = db.(*database.DB).QueryRow( `SELECT d.build_log, d.runtime_log, p.id FROM deployments d JOIN services s ON d.service_id = s.id JOIN projects p ON s.project_id = p.id WHERE d.id = $1`, deploymentID, ).Scan(&buildLog, &runtimeLog, &projectID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) return } if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed { c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) return } logType := c.DefaultQuery("type", "all") var logs []LogEntry parseLogs := func(logContent string, stream string) []LogEntry { var entries []LogEntry scanner := bufio.NewScanner(strings.NewReader(logContent)) for scanner.Scan() { line := scanner.Text() if line == "" { continue } entries = append(entries, LogEntry{ Timestamp: time.Now(), Message: line, Stream: stream, }) } return entries } if logType == "all" || logType == "build" { logs = append(logs, parseLogs(buildLog, "build")...) } if logType == "all" || logType == "runtime" { logs = append(logs, parseLogs(runtimeLog, "runtime")...) } c.JSON(http.StatusOK, gin.H{ "logs": logs, "build_log": buildLog, "runtime_log": runtimeLog, }) } func stripDockerLogHeader(line string) string { if len(line) > 8 && (line[0] == 1 || line[0] == 2) { return line[8:] } return line }