Files
dockhand/routes/logs/LogViewer.svelte
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

322 lines
9.6 KiB
Svelte

<script lang="ts">
import { RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, X, Type } from 'lucide-svelte';
import * as Select from '$lib/components/ui/select';
import { themeStore } from '$lib/stores/theme';
import { getMonospaceFont } from '$lib/themes';
interface Props {
logs: string;
containerName: string;
loading?: boolean;
autoRefresh?: boolean;
autoScroll?: boolean;
onRefresh?: () => void;
onAutoRefreshChange?: (value: boolean) => void;
onAutoScrollChange?: (value: boolean) => void;
class?: string;
}
let {
logs,
containerName,
loading = false,
autoRefresh = true,
autoScroll = true,
onRefresh,
onAutoRefreshChange,
onAutoScrollChange,
class: className = ''
}: Props = $props();
let logsRef: HTMLDivElement;
let wordWrap = $state(true);
let fontSize = $state(12);
// Search state
let logSearchActive = $state(false);
let logSearchQuery = $state('');
let currentMatchIndex = $state(0);
let matchCount = $state(0);
let logSearchInputRef: HTMLInputElement;
const fontSizeOptions = [10, 12, 14, 16];
// Get terminal font family from theme preferences
let terminalFontFamily = $derived(() => {
const fontMeta = getMonospaceFont($themeStore.terminalFont);
return fontMeta?.family || 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace';
});
// Auto-scroll when logs change
$effect(() => {
if (autoScroll && logsRef && logs) {
setTimeout(() => {
logsRef.scrollTop = logsRef.scrollHeight;
}, 50);
}
});
// Copy logs to clipboard
async function copyLogs() {
if (logs) {
try {
await navigator.clipboard.writeText(logs);
} catch (err) {
console.error('Failed to copy:', err);
}
}
}
// Download logs as txt file
function downloadLogs() {
if (logs && containerName) {
const blob = new Blob([logs], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${containerName}-logs.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}
// Search functions
function toggleLogSearch() {
logSearchActive = !logSearchActive;
if (logSearchActive) {
setTimeout(() => logSearchInputRef?.focus(), 50);
} else {
logSearchQuery = '';
currentMatchIndex = 0;
matchCount = 0;
}
}
function closeLogSearch() {
logSearchActive = false;
logSearchQuery = '';
currentMatchIndex = 0;
matchCount = 0;
}
function navigateMatch(direction: 'prev' | 'next') {
if (!logsRef || matchCount === 0) return;
const matches = logsRef.querySelectorAll('.search-match');
if (matches.length === 0) return;
matches[currentMatchIndex]?.classList.remove('current-match');
if (direction === 'next') {
currentMatchIndex = (currentMatchIndex + 1) % matches.length;
} else {
currentMatchIndex = (currentMatchIndex - 1 + matches.length) % matches.length;
}
const currentEl = matches[currentMatchIndex];
if (currentEl) {
currentEl.classList.add('current-match');
currentEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
function handleLogSearchKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) {
navigateMatch('prev');
} else {
navigateMatch('next');
}
} else if (e.key === 'Escape') {
closeLogSearch();
}
}
// Escape HTML to prevent XSS
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Highlighted logs with search matches
let highlightedLogs = $derived(() => {
const escaped = escapeHtml(logs || '');
if (!logSearchQuery.trim()) return escaped;
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapeHtml(query);
const regex = new RegExp(`(${escapedQuery})`, 'gi');
return escaped.replace(regex, '<mark class="search-match">$1</mark>');
});
// Update match count after render
$effect(() => {
const html = highlightedLogs();
if (logSearchQuery && logsRef) {
setTimeout(() => {
const matches = logsRef.querySelectorAll('.search-match');
matchCount = matches.length;
currentMatchIndex = 0;
if (matches.length > 0) {
matches[0].classList.add('current-match');
matches[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
} else {
matchCount = 0;
currentMatchIndex = 0;
}
});
</script>
<div class="flex flex-col bg-zinc-950 rounded-lg border border-zinc-800 overflow-hidden {className}">
<!-- Header bar -->
<div class="flex items-center justify-between px-3 py-1.5 border-b border-zinc-800 bg-zinc-900/50 shrink-0">
<div class="flex items-center gap-2">
{#if loading}
<RefreshCw class="w-3 h-3 text-zinc-500 animate-spin" />
{/if}
</div>
<div class="flex items-center gap-2">
<!-- Auto-refresh button -->
<button
onclick={() => onAutoRefreshChange?.(!autoRefresh)}
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs transition-colors {autoRefresh ? 'bg-amber-500/20 ring-1 ring-amber-500/50 text-amber-400' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'}"
title="Toggle auto-refresh"
>
<RefreshCw class="w-3 h-3" />
</button>
<!-- Auto-scroll button -->
<button
onclick={() => onAutoScrollChange?.(!autoScroll)}
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs transition-colors {autoScroll ? 'bg-amber-500/20 ring-1 ring-amber-500/50 text-amber-400' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'}"
title="Toggle auto-scroll"
>
<ArrowDownToLine class="w-3 h-3" />
</button>
<!-- Font size -->
<Select.Root type="single" value={String(fontSize)} onValueChange={(v) => fontSize = Number(v)}>
<Select.Trigger class="h-6 w-16 bg-zinc-800 border-zinc-700 text-xs text-zinc-300 px-1.5">
<Type class="w-3 h-3 mr-1 text-zinc-400" />
<span>{fontSize}px</span>
</Select.Trigger>
<Select.Content>
{#each fontSizeOptions as size}
<Select.Item value={String(size)} label="{size}px">
<Type class="w-3 h-3 mr-1.5 text-muted-foreground" />
{size}px
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<!-- Word wrap -->
<button
onclick={() => wordWrap = !wordWrap}
class="p-1 rounded hover:bg-zinc-800 transition-colors {wordWrap ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : ''}"
title="Toggle word wrap"
>
<WrapText class="w-3 h-3 transition-colors {wordWrap ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}" />
</button>
<!-- Search -->
{#if logSearchActive}
<div class="flex items-center gap-1 bg-zinc-800 rounded px-1.5 py-0.5">
<Search class="w-3 h-3 text-amber-400" />
<input
bind:this={logSearchInputRef}
type="text"
placeholder="Search..."
bind:value={logSearchQuery}
onkeydown={handleLogSearchKeydown}
class="bg-transparent border-none outline-none text-xs text-zinc-200 w-20 placeholder:text-zinc-500"
/>
{#if matchCount > 0}
<span class="text-xs text-zinc-400">{currentMatchIndex + 1}/{matchCount}</span>
{:else if logSearchQuery}
<span class="text-xs text-zinc-500">0/0</span>
{/if}
<button onclick={() => navigateMatch('prev')} class="p-0.5 rounded hover:bg-zinc-700" title="Previous">
<ChevronUp class="w-3 h-3 text-zinc-400" />
</button>
<button onclick={() => navigateMatch('next')} class="p-0.5 rounded hover:bg-zinc-700" title="Next">
<ChevronDown class="w-3 h-3 text-zinc-400" />
</button>
<button onclick={closeLogSearch} class="p-0.5 rounded hover:bg-zinc-700" title="Close">
<X class="w-3 h-3 text-zinc-400" />
</button>
</div>
{:else}
<button
onclick={toggleLogSearch}
class="p-1 rounded hover:bg-zinc-800 transition-colors"
title="Search logs"
>
<Search class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
</button>
{/if}
<!-- Copy -->
<button
onclick={copyLogs}
class="p-1 rounded hover:bg-zinc-800 transition-colors"
title="Copy logs"
>
<Copy class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
</button>
<!-- Download -->
<button
onclick={downloadLogs}
class="p-1 rounded hover:bg-zinc-800 transition-colors"
title="Download logs"
>
<Download class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
</button>
<!-- Refresh -->
<button
onclick={() => onRefresh?.()}
class="p-1 rounded hover:bg-zinc-800 transition-colors"
title="Refresh logs"
>
<RefreshCw class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
</button>
</div>
</div>
<!-- Logs content -->
<div bind:this={logsRef} class="flex-1 overflow-auto p-4">
{#if logs}
<pre class="text-zinc-50 {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'}" style="font-size: {fontSize}px; font-family: {terminalFontFamily()};">{@html highlightedLogs()}</pre>
{:else if loading}
<div class="flex items-center justify-center h-full text-muted-foreground">
<RefreshCw class="w-5 h-5 animate-spin mr-2" />
Loading logs...
</div>
{:else}
<p class="text-zinc-500 text-sm">No logs available</p>
{/if}
</div>
</div>
<style>
:global(.search-match) {
background-color: rgba(234, 179, 8, 0.4);
color: #fef3c7;
border-radius: 2px;
padding: 1px 2px;
box-shadow: 0 0 4px rgba(234, 179, 8, 0.5);
}
:global(.search-match.current-match) {
background-color: rgba(234, 179, 8, 0.8);
color: #1a1a1a;
font-weight: 600;
box-shadow: 0 0 8px rgba(234, 179, 8, 0.9), 0 0 16px rgba(234, 179, 8, 0.5);
outline: 2px solid rgb(250, 204, 21);
}
</style>