diff --git a/internal/api/docker.go b/internal/api/docker.go index ca1a442..27c504f 100644 --- a/internal/api/docker.go +++ b/internal/api/docker.go @@ -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) { diff --git a/internal/api/router.go b/internal/api/router.go index fc09e91..b92461f 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -204,6 +204,7 @@ func (s *Server) Router() chi.Router { r.Get("/stages/{stage}/env", s.listStageEnv) r.Get("/stages/{stage}/instances", s.listInstances) r.Get("/stages/{stage}/instances/{iid}/stats", s.getInstanceStats) + r.Get("/stages/{stage}/instances/{iid}/logs", s.streamContainerLogs) r.Get("/images", s.listProjectImages) r.Get("/volumes", s.listVolumes) r.Get("/volumes/{volId}/browse", s.browseVolume) diff --git a/internal/docker/container.go b/internal/docker/container.go index bec6aeb..71b5d88 100644 --- a/internal/docker/container.go +++ b/internal/docker/container.go @@ -3,6 +3,7 @@ package docker import ( "context" "fmt" + "io" "regexp" "strconv" "strings" @@ -266,6 +267,23 @@ func (c *Client) ListContainers(ctx context.Context, labelFilters map[string]str return result, nil } +// ContainerLogs returns a log stream for a container. +// If follow is true, the stream stays open for new log lines. +// tail specifies the number of lines from the end to return (e.g., "200"). +func (c *Client) ContainerLogs(ctx context.Context, containerID string, follow bool, tail string) (io.ReadCloser, error) { + result, err := c.api.ContainerLogs(ctx, containerID, client.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: follow, + Tail: tail, + Timestamps: true, + }) + if err != nil { + return nil, fmt.Errorf("container logs %s: %w", containerID, err) + } + return result, nil +} + // IsContainerRunning checks if a container is in the "running" state. func (c *Client) IsContainerRunning(ctx context.Context, containerID string) (bool, error) { inspectResult, err := c.api.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{}) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1e0146e..c6a382f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -279,6 +279,12 @@ export function listProxyRoutes(): Promise { // ── Docker Management ────────────────────────────────────────────── +export function fetchContainerLogs( + projectId: string, stageId: string, instanceId: string, tail = 200 +): Promise { + return get(`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/logs?tail=${tail}`); +} + export function listProjectImages(projectId: string): Promise { return get(`/api/projects/${projectId}/images`); } diff --git a/web/src/lib/components/ContainerLogs.svelte b/web/src/lib/components/ContainerLogs.svelte new file mode 100644 index 0000000..3dddadb --- /dev/null +++ b/web/src/lib/components/ContainerLogs.svelte @@ -0,0 +1,150 @@ + + + +
+ +
+
+

{$t('logs.title')}

+ + +
+ +
+ + +
+ {#if loading} +
+ + {$t('logs.loading')} +
+ {:else if error} +

{error}

+ {:else if lines.length === 0} +

{$t('logs.noLogs')}

+ {:else} + {#each lines as line, i} +
{line}
+ {/each} + {/if} +
+
diff --git a/web/src/lib/components/InstanceCard.svelte b/web/src/lib/components/InstanceCard.svelte index 1b66672..bdb2f45 100644 --- a/web/src/lib/components/InstanceCard.svelte +++ b/web/src/lib/components/InstanceCard.svelte @@ -5,8 +5,9 @@ import type { Instance } from '$lib/types'; import StatusBadge from './StatusBadge.svelte'; import ContainerStats from './ContainerStats.svelte'; + import ContainerLogs from './ContainerLogs.svelte'; import ConfirmDialog from './ConfirmDialog.svelte'; - import { IconPlay, IconStop, IconRestart, IconTrash, IconExternalLink } from '$lib/components/icons'; + import { IconPlay, IconStop, IconRestart, IconTrash, IconExternalLink, IconEvents } from '$lib/components/icons'; import { t } from '$lib/i18n'; import * as api from '$lib/api'; @@ -22,6 +23,7 @@ let loading = $state(false); let error = $state(''); let confirmAction = $state<'stop' | 'restart' | 'remove' | null>(null); + let showLogs = $state(false); const subdomainUrl = $derived( instance.subdomain && domain @@ -133,6 +135,14 @@ {/if} +