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
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"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)
|
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.
|
// pruneImages handles POST /api/docker/prune-images.
|
||||||
// Only removes images that belong to Docker Watcher projects (not all system images).
|
// Only removes images that belong to Docker Watcher projects (not all system images).
|
||||||
func (s *Server) pruneImages(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) pruneImages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.Get("/stages/{stage}/env", s.listStageEnv)
|
r.Get("/stages/{stage}/env", s.listStageEnv)
|
||||||
r.Get("/stages/{stage}/instances", s.listInstances)
|
r.Get("/stages/{stage}/instances", s.listInstances)
|
||||||
r.Get("/stages/{stage}/instances/{iid}/stats", s.getInstanceStats)
|
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("/images", s.listProjectImages)
|
||||||
r.Get("/volumes", s.listVolumes)
|
r.Get("/volumes", s.listVolumes)
|
||||||
r.Get("/volumes/{volId}/browse", s.browseVolume)
|
r.Get("/volumes/{volId}/browse", s.browseVolume)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package docker
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -266,6 +267,23 @@ func (c *Client) ListContainers(ctx context.Context, labelFilters map[string]str
|
|||||||
return result, nil
|
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.
|
// IsContainerRunning checks if a container is in the "running" state.
|
||||||
func (c *Client) IsContainerRunning(ctx context.Context, containerID string) (bool, error) {
|
func (c *Client) IsContainerRunning(ctx context.Context, containerID string) (bool, error) {
|
||||||
inspectResult, err := c.api.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
|
inspectResult, err := c.api.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
|
||||||
|
|||||||
@@ -279,6 +279,12 @@ export function listProxyRoutes(): Promise<ProxyRoute[]> {
|
|||||||
|
|
||||||
// ── Docker Management ──────────────────────────────────────────────
|
// ── Docker Management ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function fetchContainerLogs(
|
||||||
|
projectId: string, stageId: string, instanceId: string, tail = 200
|
||||||
|
): Promise<string[]> {
|
||||||
|
return get<string[]>(`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/logs?tail=${tail}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function listProjectImages(projectId: string): Promise<LocalImage[]> {
|
export function listProjectImages(projectId: string): Promise<LocalImage[]> {
|
||||||
return get<LocalImage[]>(`/api/projects/${projectId}/images`);
|
return get<LocalImage[]>(`/api/projects/${projectId}/images`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<!--
|
||||||
|
Container log viewer with tail line limit and auto-scroll.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { fetchContainerLogs } from '$lib/api';
|
||||||
|
import { getAuthToken } from '$lib/auth';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import { IconLoader, IconX } from '$lib/components/icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
stageId: string;
|
||||||
|
instanceId: string;
|
||||||
|
onclose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projectId, stageId, instanceId, onclose }: Props = $props();
|
||||||
|
|
||||||
|
let lines = $state<string[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let tailCount = $state(200);
|
||||||
|
let following = $state(false);
|
||||||
|
let logContainer: HTMLDivElement | undefined = $state();
|
||||||
|
let eventSource: EventSource | null = null;
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
lines = await fetchContainerLogs(projectId, stageId, instanceId, tailCount);
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : 'Failed to load logs';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startFollowing() {
|
||||||
|
if (eventSource) return;
|
||||||
|
following = true;
|
||||||
|
const token = getAuthToken();
|
||||||
|
const url = `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/logs?follow=true&tail=0&token=${token}`;
|
||||||
|
eventSource = new EventSource(url);
|
||||||
|
|
||||||
|
eventSource.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.line) {
|
||||||
|
lines = [...lines, data.line];
|
||||||
|
// Trim to max lines.
|
||||||
|
if (lines.length > tailCount * 2) {
|
||||||
|
lines = lines.slice(-tailCount);
|
||||||
|
}
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
stopFollowing();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopFollowing() {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
eventSource = null;
|
||||||
|
}
|
||||||
|
following = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (logContainer) {
|
||||||
|
logContainer.scrollTop = logContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTailChange(e: Event) {
|
||||||
|
const value = (e.target as HTMLSelectElement).value;
|
||||||
|
tailCount = parseInt(value, 10);
|
||||||
|
stopFollowing();
|
||||||
|
loadLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load on mount.
|
||||||
|
$effect(() => { loadLogs(); });
|
||||||
|
|
||||||
|
onDestroy(() => { stopFollowing(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-page)] shadow-lg">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-[var(--border-primary)] px-4 py-2.5">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h3 class="text-sm font-semibold text-[var(--text-primary)]">{$t('logs.title')}</h3>
|
||||||
|
<select
|
||||||
|
value={String(tailCount)}
|
||||||
|
onchange={handleTailChange}
|
||||||
|
class="rounded border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-0.5 text-xs text-[var(--text-secondary)] focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="50">50 {$t('logs.lines')}</option>
|
||||||
|
<option value="200">200 {$t('logs.lines')}</option>
|
||||||
|
<option value="500">500 {$t('logs.lines')}</option>
|
||||||
|
<option value="1000">1000 {$t('logs.lines')}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-2 py-0.5 text-xs font-medium transition-colors {following
|
||||||
|
? 'bg-emerald-600 text-white'
|
||||||
|
: 'border border-[var(--border-input)] text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'}"
|
||||||
|
onclick={() => following ? stopFollowing() : startFollowing()}
|
||||||
|
>
|
||||||
|
{following ? $t('logs.following') : $t('logs.follow')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onclose}
|
||||||
|
class="rounded p-1 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<IconX size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Log content -->
|
||||||
|
<div
|
||||||
|
bind:this={logContainer}
|
||||||
|
class="h-80 overflow-auto bg-gray-950 p-3 font-mono text-xs leading-relaxed text-gray-300"
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center gap-2 text-gray-500">
|
||||||
|
<IconLoader size={14} />
|
||||||
|
{$t('logs.loading')}
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<p class="text-red-400">{error}</p>
|
||||||
|
{:else if lines.length === 0}
|
||||||
|
<p class="text-gray-500">{$t('logs.noLogs')}</p>
|
||||||
|
{:else}
|
||||||
|
{#each lines as line, i}
|
||||||
|
<div class="hover:bg-gray-900/50 px-1 -mx-1 rounded whitespace-pre-wrap break-all">{line}</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -5,8 +5,9 @@
|
|||||||
import type { Instance } from '$lib/types';
|
import type { Instance } from '$lib/types';
|
||||||
import StatusBadge from './StatusBadge.svelte';
|
import StatusBadge from './StatusBadge.svelte';
|
||||||
import ContainerStats from './ContainerStats.svelte';
|
import ContainerStats from './ContainerStats.svelte';
|
||||||
|
import ContainerLogs from './ContainerLogs.svelte';
|
||||||
import ConfirmDialog from './ConfirmDialog.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 { t } from '$lib/i18n';
|
||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let confirmAction = $state<'stop' | 'restart' | 'remove' | null>(null);
|
let confirmAction = $state<'stop' | 'restart' | 'remove' | null>(null);
|
||||||
|
let showLogs = $state(false);
|
||||||
|
|
||||||
const subdomainUrl = $derived(
|
const subdomainUrl = $derived(
|
||||||
instance.subdomain && domain
|
instance.subdomain && domain
|
||||||
@@ -133,6 +135,14 @@
|
|||||||
<IconPlay size={16} />
|
<IconPlay size={16} />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300 transition-all duration-150"
|
||||||
|
title={$t('logs.title')}
|
||||||
|
onclick={() => { showLogs = !showLogs; }}
|
||||||
|
>
|
||||||
|
<IconEvents size={16} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 disabled:opacity-50 transition-all duration-150 active:animate-press"
|
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 disabled:opacity-50 transition-all duration-150 active:animate-press"
|
||||||
@@ -149,6 +159,17 @@
|
|||||||
<ContainerStats projectId={projectId} stageId={instance.stage_id} instanceId={instance.id} />
|
<ContainerStats projectId={projectId} stageId={instance.stage_id} instanceId={instance.id} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showLogs}
|
||||||
|
<div class="mt-2">
|
||||||
|
<ContainerLogs
|
||||||
|
{projectId}
|
||||||
|
stageId={instance.stage_id}
|
||||||
|
instanceId={instance.id}
|
||||||
|
onclose={() => { showLogs = false; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="mt-2 text-xs text-[var(--color-danger)]">{error}</p>
|
<p class="mt-2 text-xs text-[var(--color-danger)]">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -680,6 +680,14 @@
|
|||||||
"route": "route",
|
"route": "route",
|
||||||
"routes": "routes"
|
"routes": "routes"
|
||||||
},
|
},
|
||||||
|
"logs": {
|
||||||
|
"title": "Container Logs",
|
||||||
|
"lines": "lines",
|
||||||
|
"follow": "Follow",
|
||||||
|
"following": "Following...",
|
||||||
|
"loading": "Loading logs...",
|
||||||
|
"noLogs": "No log output"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Event Log",
|
"title": "Event Log",
|
||||||
"noEvents": "No events found",
|
"noEvents": "No events found",
|
||||||
|
|||||||
@@ -680,6 +680,14 @@
|
|||||||
"route": "маршрут",
|
"route": "маршрут",
|
||||||
"routes": "маршрутов"
|
"routes": "маршрутов"
|
||||||
},
|
},
|
||||||
|
"logs": {
|
||||||
|
"title": "Логи контейнера",
|
||||||
|
"lines": "строк",
|
||||||
|
"follow": "Следить",
|
||||||
|
"following": "Слежение...",
|
||||||
|
"loading": "Загрузка логов...",
|
||||||
|
"noLogs": "Нет вывода логов"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Журнал событий",
|
"title": "Журнал событий",
|
||||||
"noEvents": "Событий не найдено",
|
"noEvents": "Событий не найдено",
|
||||||
|
|||||||
Reference in New Issue
Block a user