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:
2026-04-05 14:04:45 +03:00
parent ac3132d172
commit d03cc3c811
8 changed files with 322 additions and 1 deletions
+6
View File
@@ -279,6 +279,12 @@ export function listProxyRoutes(): Promise<ProxyRoute[]> {
// ── 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[]> {
return get<LocalImage[]>(`/api/projects/${projectId}/images`);
}
+150
View File
@@ -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>
+22 -1
View File
@@ -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 @@
<IconPlay size={16} />
</button>
{/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
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"
@@ -149,6 +159,17 @@
<ContainerStats projectId={projectId} stageId={instance.stage_id} instanceId={instance.id} />
{/if}
{#if showLogs}
<div class="mt-2">
<ContainerLogs
{projectId}
stageId={instance.stage_id}
instanceId={instance.id}
onclose={() => { showLogs = false; }}
/>
</div>
{/if}
{#if error}
<p class="mt-2 text-xs text-[var(--color-danger)]">{error}</p>
{/if}
+8
View File
@@ -680,6 +680,14 @@
"route": "route",
"routes": "routes"
},
"logs": {
"title": "Container Logs",
"lines": "lines",
"follow": "Follow",
"following": "Following...",
"loading": "Loading logs...",
"noLogs": "No log output"
},
"events": {
"title": "Event Log",
"noEvents": "No events found",
+8
View File
@@ -680,6 +680,14 @@
"route": "маршрут",
"routes": "маршрутов"
},
"logs": {
"title": "Логи контейнера",
"lines": "строк",
"follow": "Следить",
"following": "Слежение...",
"loading": "Загрузка логов...",
"noLogs": "Нет вывода логов"
},
"events": {
"title": "Журнал событий",
"noEvents": "Событий не найдено",