mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-09 13:24:51 +00:00
Initial commit
This commit is contained in:
321
routes/logs/LogViewer.svelte
Normal file
321
routes/logs/LogViewer.svelte
Normal file
@@ -0,0 +1,321 @@
|
||||
<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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 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>
|
||||
Reference in New Issue
Block a user