feat: container logs viewer with SSE streaming and line limiter
- Add GET /api/projects/{id}/stages/{stage}/instances/{iid}/logs endpoint
- Supports JSON mode (returns array of lines) and SSE mode (streams in real-time)
- Docker log stream header (8-byte prefix) stripped automatically
- ContainerLogs component with:
- Tail line selector (50/200/500/1000)
- Follow button for real-time streaming via SSE
- Auto-scroll to bottom
- Dark terminal-style display
- Close button
- Logs button (events icon) on each instance card
- i18n keys in EN and RU
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -41,6 +45,111 @@ func (s *Server) listProjectImages(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, images)
|
||||
}
|
||||
|
||||
// streamContainerLogs handles GET /api/projects/{id}/stages/{stage}/instances/{iid}/logs.
|
||||
// Streams container logs via SSE. Supports query params:
|
||||
// - tail: number of lines from end (default "200")
|
||||
// - follow: "true" to stream new lines in real-time
|
||||
func (s *Server) streamContainerLogs(w http.ResponseWriter, r *http.Request) {
|
||||
instanceID := chi.URLParam(r, "iid")
|
||||
|
||||
inst, err := s.store.GetInstanceByID(instanceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "instance")
|
||||
return
|
||||
}
|
||||
slog.Error("failed to get instance", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
if inst.ContainerID == "" {
|
||||
respondError(w, http.StatusBadRequest, "instance has no container")
|
||||
return
|
||||
}
|
||||
|
||||
if s.docker == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "Docker is not available")
|
||||
return
|
||||
}
|
||||
|
||||
tail := r.URL.Query().Get("tail")
|
||||
if tail == "" {
|
||||
tail = "200"
|
||||
}
|
||||
follow := r.URL.Query().Get("follow") == "true"
|
||||
|
||||
// Check if client accepts SSE.
|
||||
accept := r.Header.Get("Accept")
|
||||
isSSE := strings.Contains(accept, "text/event-stream")
|
||||
|
||||
logReader, err := s.docker.ContainerLogs(r.Context(), inst.ContainerID, follow && isSSE, tail)
|
||||
if err != nil {
|
||||
slog.Error("failed to get container logs", "instance", instanceID, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to get container logs")
|
||||
return
|
||||
}
|
||||
defer logReader.Close()
|
||||
|
||||
if !isSSE {
|
||||
// JSON mode: read all lines and return as array.
|
||||
scanner := bufio.NewScanner(logReader)
|
||||
var lines []string
|
||||
for scanner.Scan() {
|
||||
line := sanitizeDockerLogLine(scanner.Text())
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if lines == nil {
|
||||
lines = []string{}
|
||||
}
|
||||
respondJSON(w, http.StatusOK, lines)
|
||||
return
|
||||
}
|
||||
|
||||
// SSE mode: stream lines as they arrive.
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
respondError(w, http.StatusInternalServerError, "streaming not supported")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
scanner := bufio.NewScanner(logReader)
|
||||
for scanner.Scan() {
|
||||
line := sanitizeDockerLogLine(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(map[string]string{"line": line})
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
|
||||
// Check if client disconnected.
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeDockerLogLine strips the Docker log stream header (8-byte prefix)
|
||||
// that Docker adds to non-TTY container logs.
|
||||
func sanitizeDockerLogLine(line string) string {
|
||||
// Docker multiplexed stream: first 8 bytes are header (stream type + size).
|
||||
// If the line starts with a non-printable byte followed by 0x00 0x00 0x00, strip 8 bytes.
|
||||
if len(line) > 8 && (line[0] == 1 || line[0] == 2) && line[1] == 0 && line[2] == 0 && line[3] == 0 {
|
||||
return line[8:]
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// pruneImages handles POST /api/docker/prune-images.
|
||||
// Only removes images that belong to Docker Watcher projects (not all system images).
|
||||
func (s *Server) pruneImages(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user