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:
@@ -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>
|
||||
Reference in New Issue
Block a user