mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-02 21:19:05 +00:00
927 lines
29 KiB
Svelte
927 lines
29 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { X, GripHorizontal, RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, Sun, Moon, Wifi, WifiOff, Pause, Play } from 'lucide-svelte';
|
|
import * as Select from '$lib/components/ui/select';
|
|
import { appSettings } from '$lib/stores/settings';
|
|
import { themeStore } from '$lib/stores/theme';
|
|
import { getMonospaceFont } from '$lib/themes';
|
|
|
|
interface Props {
|
|
containerId: string;
|
|
containerName: string;
|
|
visible: boolean;
|
|
envId: number | null;
|
|
fillHeight?: boolean;
|
|
showCloseButton?: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
let { containerId, containerName, visible, envId, fillHeight = false, showCloseButton = true, onClose }: Props = $props();
|
|
|
|
let logs = $state('');
|
|
let loading = $state(false);
|
|
let logsRef: HTMLDivElement;
|
|
let panelRef: HTMLDivElement;
|
|
let autoScroll = $state(true);
|
|
let wordWrap = $state(true);
|
|
let fontSize = $state(12);
|
|
|
|
// SSE Streaming state
|
|
let streamingEnabled = $state(true);
|
|
let isConnected = $state(false);
|
|
let connectionError = $state<string | null>(null);
|
|
let eventSource: EventSource | null = null;
|
|
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let reconnectAttempts = $state(0);
|
|
const MAX_RECONNECT_ATTEMPTS = 5;
|
|
const RECONNECT_DELAY = 3000;
|
|
const OFFLINE_POLL_INTERVAL = 5000; // Check every 5 seconds when offline
|
|
let offlinePollingInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
// Search state
|
|
let logSearchActive = $state(false);
|
|
let logSearchQuery = $state('');
|
|
let currentMatchIndex = $state(0);
|
|
let matchCount = $state(0);
|
|
let logSearchInputRef: HTMLInputElement | undefined;
|
|
|
|
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';
|
|
});
|
|
|
|
// Panel height with localStorage persistence
|
|
const STORAGE_KEY = 'dockhand-logs-panel-height';
|
|
const SETTINGS_STORAGE_KEY = 'dockhand-logs-settings';
|
|
const DEFAULT_HEIGHT = 240;
|
|
const MIN_HEIGHT = 150;
|
|
const MAX_HEIGHT = 600;
|
|
|
|
let panelHeight = $state(DEFAULT_HEIGHT);
|
|
let isDragging = $state(false);
|
|
let darkMode = $state(true);
|
|
|
|
// Load all saved settings from localStorage
|
|
function loadSettings() {
|
|
if (typeof window !== 'undefined') {
|
|
// Load panel height
|
|
const savedHeight = localStorage.getItem(STORAGE_KEY);
|
|
if (savedHeight) {
|
|
const h = parseInt(savedHeight);
|
|
if (!isNaN(h) && h >= MIN_HEIGHT && h <= MAX_HEIGHT) {
|
|
panelHeight = h;
|
|
}
|
|
}
|
|
// Load other settings
|
|
const savedSettings = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
|
if (savedSettings) {
|
|
try {
|
|
const settings = JSON.parse(savedSettings);
|
|
if (settings.darkMode !== undefined) darkMode = settings.darkMode;
|
|
if (settings.wordWrap !== undefined) wordWrap = settings.wordWrap;
|
|
if (settings.fontSize !== undefined) fontSize = settings.fontSize;
|
|
if (settings.autoScroll !== undefined) autoScroll = settings.autoScroll;
|
|
if (settings.streamingEnabled !== undefined) streamingEnabled = settings.streamingEnabled;
|
|
} catch {
|
|
// Ignore parse errors
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save settings to localStorage
|
|
function saveSettings() {
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify({
|
|
darkMode,
|
|
wordWrap,
|
|
fontSize,
|
|
autoScroll,
|
|
streamingEnabled
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Toggle theme
|
|
function toggleTheme() {
|
|
darkMode = !darkMode;
|
|
saveSettings();
|
|
}
|
|
|
|
// Save height to localStorage
|
|
function saveHeight() {
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem(STORAGE_KEY, String(panelHeight));
|
|
}
|
|
}
|
|
|
|
// Drag handle functionality
|
|
function startDrag(e: MouseEvent) {
|
|
e.preventDefault();
|
|
isDragging = true;
|
|
document.addEventListener('mousemove', handleDrag);
|
|
document.addEventListener('mouseup', stopDrag);
|
|
}
|
|
|
|
function handleDrag(e: MouseEvent) {
|
|
if (!isDragging || !panelRef) return;
|
|
const newHeight = window.innerHeight - e.clientY;
|
|
panelHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, newHeight));
|
|
}
|
|
|
|
function stopDrag() {
|
|
isDragging = false;
|
|
document.removeEventListener('mousemove', handleDrag);
|
|
document.removeEventListener('mouseup', stopDrag);
|
|
saveHeight();
|
|
}
|
|
|
|
function appendEnvParam(url: string, envId: number | null): string {
|
|
if (!envId) return url;
|
|
const separator = url.includes('?') ? '&' : '?';
|
|
return `${url}${separator}env=${envId}`;
|
|
}
|
|
|
|
// Start SSE streaming for logs
|
|
function startStreaming() {
|
|
if (!containerId || !streamingEnabled) return;
|
|
stopStreaming(false); // Don't reset reconnect attempts
|
|
|
|
connectionError = null;
|
|
const currentContainerId = containerId; // Capture for closure
|
|
|
|
try {
|
|
const url = appendEnvParam(`/api/containers/${currentContainerId}/logs/stream?tail=500`, envId);
|
|
eventSource = new EventSource(url);
|
|
|
|
eventSource.addEventListener('connected', () => {
|
|
isConnected = true;
|
|
loading = false;
|
|
connectionError = null;
|
|
reconnectAttempts = 0; // Reset on successful connection
|
|
stopOfflinePolling(); // Stop polling since we're connected
|
|
});
|
|
|
|
eventSource.addEventListener('log', (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (data.text) {
|
|
// Add container name prefix to each line if available
|
|
let text = data.text;
|
|
if (data.containerName) {
|
|
// Split by lines, prefix each non-empty line, rejoin
|
|
const lines = text.split('\n');
|
|
text = lines.map((line: string, i: number) => {
|
|
// Don't prefix empty lines at the end
|
|
if (line === '' && i === lines.length - 1) return line;
|
|
if (line === '') return line;
|
|
return `[${data.containerName}] ${line}`;
|
|
}).join('\n');
|
|
}
|
|
logs += text;
|
|
|
|
// Apply log buffer size limit (convert KB to characters, roughly 1 char = 1 byte)
|
|
const maxSize = $appSettings.logBufferSizeKb * 1024;
|
|
if (logs.length > maxSize) {
|
|
// Truncate from the beginning, keeping 80% of max size
|
|
logs = logs.substring(logs.length - Math.floor(maxSize * 0.8));
|
|
}
|
|
|
|
// Auto-scroll to bottom
|
|
if (autoScroll && logsRef) {
|
|
setTimeout(() => {
|
|
logsRef.scrollTop = logsRef.scrollHeight;
|
|
}, 50);
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore parse errors
|
|
}
|
|
});
|
|
|
|
eventSource.addEventListener('error', (event: Event) => {
|
|
try {
|
|
const data = JSON.parse((event as MessageEvent).data);
|
|
connectionError = data.error || 'Connection error';
|
|
} catch {
|
|
connectionError = 'Connection error';
|
|
}
|
|
handleStreamError();
|
|
});
|
|
|
|
eventSource.addEventListener('end', () => {
|
|
// Container stopped or stream ended normally
|
|
// Close EventSource immediately to prevent onerror from firing
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
eventSource = null;
|
|
}
|
|
isConnected = false;
|
|
connectionError = null;
|
|
|
|
// Fetch historical logs to get any final output
|
|
fetchLogs();
|
|
|
|
// Start polling for container restart
|
|
startOfflinePolling();
|
|
});
|
|
|
|
eventSource.onerror = () => {
|
|
// EventSource error - could be network issue, server down, etc.
|
|
// Skip if EventSource was already closed (e.g., by 'end' event handler)
|
|
if (!eventSource) return;
|
|
handleStreamError();
|
|
};
|
|
} catch (error) {
|
|
console.error('Failed to start streaming:', error);
|
|
connectionError = 'Failed to start streaming';
|
|
isConnected = false;
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
// Handle stream errors with reconnection logic
|
|
function handleStreamError() {
|
|
isConnected = false;
|
|
loading = false;
|
|
|
|
// Close the broken connection
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
eventSource = null;
|
|
}
|
|
|
|
// Don't reconnect if streaming is disabled or no container selected
|
|
if (!streamingEnabled || !containerId || !visible) return;
|
|
|
|
// Check if we should attempt reconnection
|
|
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
|
reconnectAttempts++;
|
|
connectionError = `Reconnecting (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`;
|
|
|
|
// Clear any existing reconnect timeout
|
|
if (reconnectTimeout) {
|
|
clearTimeout(reconnectTimeout);
|
|
}
|
|
|
|
// Schedule reconnection
|
|
reconnectTimeout = setTimeout(() => {
|
|
if (streamingEnabled && containerId && visible) {
|
|
loading = true;
|
|
startStreaming();
|
|
}
|
|
}, RECONNECT_DELAY);
|
|
} else {
|
|
// Max attempts reached - fall back to one-time log fetch
|
|
connectionError = null;
|
|
fetchLogs();
|
|
}
|
|
}
|
|
|
|
// Manual retry connection
|
|
function retryConnection() {
|
|
reconnectAttempts = 0;
|
|
connectionError = null;
|
|
logs = '';
|
|
loading = true;
|
|
startStreaming();
|
|
}
|
|
|
|
// Stop SSE streaming
|
|
function stopStreaming(resetAttempts = true) {
|
|
if (reconnectTimeout) {
|
|
clearTimeout(reconnectTimeout);
|
|
reconnectTimeout = null;
|
|
}
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
eventSource = null;
|
|
}
|
|
stopOfflinePolling();
|
|
isConnected = false;
|
|
if (resetAttempts) {
|
|
reconnectAttempts = 0;
|
|
connectionError = null;
|
|
}
|
|
}
|
|
|
|
// Offline polling - periodically try to reconnect when container restarts
|
|
function startOfflinePolling() {
|
|
stopOfflinePolling(); // Clear any existing interval
|
|
if (!streamingEnabled || !containerId || !visible) return;
|
|
|
|
offlinePollingInterval = setInterval(async () => {
|
|
// Try to reconnect
|
|
if (!isConnected && !eventSource && streamingEnabled && visible) {
|
|
reconnectAttempts = 0;
|
|
loading = true;
|
|
startStreaming();
|
|
}
|
|
}, OFFLINE_POLL_INTERVAL);
|
|
}
|
|
|
|
function stopOfflinePolling() {
|
|
if (offlinePollingInterval) {
|
|
clearInterval(offlinePollingInterval);
|
|
offlinePollingInterval = null;
|
|
}
|
|
}
|
|
|
|
// Toggle streaming on/off
|
|
function toggleStreaming() {
|
|
streamingEnabled = !streamingEnabled;
|
|
saveSettings();
|
|
if (streamingEnabled && containerId && visible) {
|
|
logs = ''; // Clear logs and start fresh stream
|
|
reconnectAttempts = 0;
|
|
connectionError = null;
|
|
loading = true;
|
|
startStreaming();
|
|
} else {
|
|
stopStreaming();
|
|
}
|
|
}
|
|
|
|
// Handle tab visibility changes (e.g., user switches back from another tab)
|
|
function handleVisibilityChange() {
|
|
if (document.visibilityState === 'visible' && visible && streamingEnabled && containerId) {
|
|
// Tab became visible - check and restore connection
|
|
|
|
// Clear any pending reconnection timer
|
|
if (reconnectTimeout) {
|
|
clearTimeout(reconnectTimeout);
|
|
reconnectTimeout = null;
|
|
}
|
|
|
|
// Reset reconnection counter for fresh attempts
|
|
reconnectAttempts = 0;
|
|
connectionError = null;
|
|
|
|
// Reconnect if EventSource is closed or in error state
|
|
if (!eventSource || eventSource.readyState !== EventSource.OPEN) {
|
|
loading = true;
|
|
startStreaming();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback fetch logs (for manual refresh or when streaming unavailable)
|
|
async function fetchLogs() {
|
|
if (!containerId) return;
|
|
loading = true;
|
|
connectionError = null;
|
|
try {
|
|
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/logs?tail=500`, envId));
|
|
const data = await response.json();
|
|
if (!response.ok) {
|
|
logs = `Failed to fetch logs: ${data.error || response.statusText}`;
|
|
return;
|
|
}
|
|
logs = data.logs || '';
|
|
// Auto-scroll to bottom
|
|
if (autoScroll && logsRef) {
|
|
setTimeout(() => {
|
|
logsRef.scrollTop = logsRef.scrollHeight;
|
|
}, 50);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch logs:', error);
|
|
logs = `Failed to fetch logs: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
function handleClose() {
|
|
stopStreaming();
|
|
logs = '';
|
|
onClose();
|
|
}
|
|
|
|
// Toggle auto-scroll
|
|
function toggleAutoScroll() {
|
|
autoScroll = !autoScroll;
|
|
saveSettings();
|
|
}
|
|
|
|
// Toggle word wrap
|
|
function toggleWordWrap() {
|
|
wordWrap = !wordWrap;
|
|
saveSettings();
|
|
}
|
|
|
|
// Update font size
|
|
function updateFontSize(newSize: number) {
|
|
fontSize = newSize;
|
|
saveSettings();
|
|
}
|
|
|
|
// 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, ''');
|
|
}
|
|
|
|
// ANSI color code to CSS class mapping
|
|
const ansiColorMap: Record<string, string> = {
|
|
'30': 'ansi-black',
|
|
'31': 'ansi-red',
|
|
'32': 'ansi-green',
|
|
'33': 'ansi-yellow',
|
|
'34': 'ansi-blue',
|
|
'35': 'ansi-magenta',
|
|
'36': 'ansi-cyan',
|
|
'37': 'ansi-white',
|
|
'90': 'ansi-bright-black',
|
|
'91': 'ansi-bright-red',
|
|
'92': 'ansi-bright-green',
|
|
'93': 'ansi-bright-yellow',
|
|
'94': 'ansi-bright-blue',
|
|
'95': 'ansi-bright-magenta',
|
|
'96': 'ansi-bright-cyan',
|
|
'97': 'ansi-bright-white',
|
|
'40': 'ansi-bg-black',
|
|
'41': 'ansi-bg-red',
|
|
'42': 'ansi-bg-green',
|
|
'43': 'ansi-bg-yellow',
|
|
'44': 'ansi-bg-blue',
|
|
'45': 'ansi-bg-magenta',
|
|
'46': 'ansi-bg-cyan',
|
|
'47': 'ansi-bg-white',
|
|
'1': 'ansi-bold',
|
|
'2': 'ansi-dim',
|
|
'3': 'ansi-italic',
|
|
'4': 'ansi-underline',
|
|
};
|
|
|
|
// Convert ANSI escape codes to HTML spans with CSS classes
|
|
function ansiToHtml(text: string): string {
|
|
// Strip control characters
|
|
let cleaned = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1A]/g, '');
|
|
|
|
// Escape HTML
|
|
let escaped = escapeHtml(cleaned);
|
|
|
|
// Match ANSI escape sequences
|
|
const ansiRegex = /\x1b\[([0-9;]*)m/g;
|
|
|
|
let result = '';
|
|
let lastIndex = 0;
|
|
let openSpans = 0;
|
|
let match;
|
|
|
|
while ((match = ansiRegex.exec(escaped)) !== null) {
|
|
result += escaped.slice(lastIndex, match.index);
|
|
lastIndex = ansiRegex.lastIndex;
|
|
|
|
const codes = match[1].split(';').filter(c => c !== '');
|
|
|
|
for (const code of codes) {
|
|
if (code === '0' || code === '39' || code === '49' || code === '') {
|
|
while (openSpans > 0) {
|
|
result += '</span>';
|
|
openSpans--;
|
|
}
|
|
} else if (ansiColorMap[code]) {
|
|
result += `<span class="${ansiColorMap[code]}">`;
|
|
openSpans++;
|
|
}
|
|
}
|
|
}
|
|
|
|
result += escaped.slice(lastIndex);
|
|
|
|
while (openSpans > 0) {
|
|
result += '</span>';
|
|
openSpans--;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Highlighted logs with search matches and ANSI color support
|
|
let highlightedLogs = $derived(() => {
|
|
const withAnsi = ansiToHtml(logs || '');
|
|
if (!logSearchQuery.trim()) return withAnsi;
|
|
|
|
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const escapedQuery = escapeHtml(query);
|
|
|
|
// Split by HTML tags and only process text parts
|
|
const parts = withAnsi.split(/(<[^>]*>)/);
|
|
const highlighted = parts.map(part => {
|
|
if (part.startsWith('<')) return part;
|
|
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
|
return part.replace(regex, '<mark class="search-match">$1</mark>');
|
|
}).join('');
|
|
|
|
return highlighted;
|
|
});
|
|
|
|
// 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;
|
|
}
|
|
});
|
|
|
|
// Start streaming when container changes and is visible
|
|
$effect(() => {
|
|
if (containerId && visible && streamingEnabled) {
|
|
logs = ''; // Clear previous logs
|
|
loading = true;
|
|
reconnectAttempts = 0;
|
|
connectionError = null;
|
|
startStreaming();
|
|
}
|
|
});
|
|
|
|
// Clean up when not visible
|
|
$effect(() => {
|
|
if (!visible) {
|
|
stopStreaming();
|
|
}
|
|
});
|
|
|
|
onMount(() => {
|
|
loadSettings();
|
|
// Listen for tab visibility changes to reconnect when user returns
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
// Chrome 77+ Page Lifecycle API - fires when frozen tab is resumed
|
|
document.addEventListener('resume', handleVisibilityChange);
|
|
});
|
|
|
|
onDestroy(() => {
|
|
// Clean up document event listeners in case destroyed mid-drag
|
|
document.removeEventListener('mousemove', handleDrag);
|
|
document.removeEventListener('mouseup', stopDrag);
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
document.removeEventListener('resume', handleVisibilityChange);
|
|
stopStreaming();
|
|
});
|
|
</script>
|
|
|
|
<!-- Always keep mounted, use display:none to hide while preserving content -->
|
|
<div
|
|
bind:this={panelRef}
|
|
class="border rounded-lg flex flex-col w-full transition-colors {darkMode ? 'bg-zinc-950 border-zinc-800' : 'bg-gray-50 border-gray-300'}"
|
|
class:hidden={!visible}
|
|
class:h-full={fillHeight}
|
|
style="{fillHeight ? '' : `height: ${panelHeight}px;`}"
|
|
>
|
|
<!-- Drag handle -->
|
|
<div
|
|
role="separator"
|
|
aria-orientation="horizontal"
|
|
class="h-2 cursor-ns-resize flex items-center justify-center transition-colors rounded-t-lg {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-200'}"
|
|
onmousedown={startDrag}
|
|
>
|
|
<GripHorizontal class="w-8 h-4 {darkMode ? 'text-zinc-600' : 'text-gray-400'}" />
|
|
</div>
|
|
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between px-3 py-1.5 border-b transition-colors {darkMode ? 'border-zinc-800 bg-zinc-900/50' : 'border-gray-300 bg-gray-100'}">
|
|
<div class="flex items-center gap-2 min-w-[120px]">
|
|
<!-- Connection status indicator -->
|
|
{#if streamingEnabled}
|
|
{#if isConnected}
|
|
<div class="flex items-center gap-1.5 transition-opacity duration-300" title="Connected - Live streaming">
|
|
<Wifi class="w-3.5 h-3.5 text-green-500" />
|
|
<span class="text-xs text-green-500 font-medium">Live</span>
|
|
</div>
|
|
{:else if loading}
|
|
<div class="flex items-center gap-1.5 transition-opacity duration-300" title="Connecting...">
|
|
<RefreshCw class="w-3.5 h-3.5 animate-spin {darkMode ? 'text-amber-500' : 'text-amber-600'}" />
|
|
<span class="text-xs {darkMode ? 'text-amber-500' : 'text-amber-600'}">Connecting...</span>
|
|
</div>
|
|
{:else if connectionError}
|
|
<button
|
|
onclick={retryConnection}
|
|
class="flex items-center gap-1.5 transition-opacity duration-300 hover:opacity-80"
|
|
title={connectionError}
|
|
>
|
|
<WifiOff class="w-3.5 h-3.5 {darkMode ? 'text-zinc-500' : 'text-gray-400'}" />
|
|
<span class="text-xs {darkMode ? 'text-zinc-500' : 'text-gray-400'}">Disconnected</span>
|
|
</button>
|
|
{:else}
|
|
<button
|
|
onclick={retryConnection}
|
|
class="flex items-center gap-1.5 transition-opacity duration-300 hover:opacity-80"
|
|
title="Click to reconnect"
|
|
>
|
|
<WifiOff class="w-3.5 h-3.5 {darkMode ? 'text-zinc-500' : 'text-gray-400'}" />
|
|
<span class="text-xs {darkMode ? 'text-zinc-500' : 'text-gray-400'}">Offline</span>
|
|
</button>
|
|
{/if}
|
|
{:else}
|
|
<div class="flex items-center gap-1.5 transition-opacity duration-300" title="Streaming paused">
|
|
<Pause class="w-3.5 h-3.5 {darkMode ? 'text-zinc-500' : 'text-gray-400'}" />
|
|
<span class="text-xs {darkMode ? 'text-zinc-500' : 'text-gray-400'}">Paused</span>
|
|
</div>
|
|
{/if}
|
|
<span class="text-xs {darkMode ? 'text-zinc-400' : 'text-gray-500'}">|</span>
|
|
<span class="text-xs font-medium {darkMode ? 'text-zinc-200' : 'text-gray-800'} truncate max-w-[150px]" title={containerName}>{containerName}</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<!-- Streaming toggle -->
|
|
<button
|
|
onclick={toggleStreaming}
|
|
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs transition-colors {streamingEnabled ? (darkMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50 text-amber-400' : 'bg-amber-500/30 ring-1 ring-amber-600/50 text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-300'}"
|
|
title={streamingEnabled ? 'Pause live streaming' : 'Resume live streaming'}
|
|
>
|
|
{#if streamingEnabled}
|
|
<Pause class="w-3 h-3" />
|
|
{:else}
|
|
<Play class="w-3 h-3" />
|
|
{/if}
|
|
</button>
|
|
<!-- Auto-scroll button -->
|
|
<button
|
|
onclick={toggleAutoScroll}
|
|
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs transition-colors {autoScroll ? (darkMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50 text-amber-400' : 'bg-amber-500/30 ring-1 ring-amber-600/50 text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-300'}"
|
|
title="Toggle auto-scroll"
|
|
>
|
|
<ArrowDownToLine class="w-3 h-3" />
|
|
</button>
|
|
<!-- Font size -->
|
|
<Select.Root type="single" value={String(fontSize)} onValueChange={(v) => updateFontSize(Number(v))}>
|
|
<Select.Trigger size="sm" class="!h-5 !py-0 w-14 text-xs px-1.5 {darkMode ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'} [&_svg]:size-3">
|
|
<span>{fontSize}px</span>
|
|
</Select.Trigger>
|
|
<Select.Content>
|
|
{#each fontSizeOptions as size}
|
|
<Select.Item value={String(size)} label="{size}px">{size}px</Select.Item>
|
|
{/each}
|
|
</Select.Content>
|
|
</Select.Root>
|
|
<!-- Word wrap -->
|
|
<button
|
|
onclick={toggleWordWrap}
|
|
class="p-1 rounded transition-colors {wordWrap ? (darkMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'bg-amber-500/30 ring-1 ring-amber-600/50') : ''} {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-300'}"
|
|
title="Toggle word wrap"
|
|
>
|
|
<WrapText class="w-3 h-3 transition-colors {wordWrap ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
|
|
</button>
|
|
<!-- Theme toggle -->
|
|
<button
|
|
onclick={toggleTheme}
|
|
class="p-1 rounded transition-colors {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-300'}"
|
|
title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
|
>
|
|
{#if darkMode}
|
|
<Sun class="w-3 h-3 {darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
|
|
{:else}
|
|
<Moon class="w-3 h-3 {darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
|
|
{/if}
|
|
</button>
|
|
<!-- Search -->
|
|
{#if logSearchActive}
|
|
<div class="flex items-center gap-1 rounded px-1.5 py-0.5 {darkMode ? 'bg-zinc-800' : 'bg-gray-200'}">
|
|
<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 w-20 {darkMode ? 'text-zinc-200 placeholder:text-zinc-500' : 'text-gray-800 placeholder:text-gray-400'}"
|
|
/>
|
|
{#if matchCount > 0}
|
|
<span class="text-xs {darkMode ? 'text-zinc-400' : 'text-gray-500'}">{currentMatchIndex + 1}/{matchCount}</span>
|
|
{:else if logSearchQuery}
|
|
<span class="text-xs {darkMode ? 'text-zinc-500' : 'text-gray-400'}">0/0</span>
|
|
{/if}
|
|
<button onclick={() => navigateMatch('prev')} class="p-0.5 rounded {darkMode ? 'hover:bg-zinc-700' : 'hover:bg-gray-300'}" title="Previous">
|
|
<ChevronUp class="w-3 h-3 {darkMode ? 'text-zinc-400' : 'text-gray-500'}" />
|
|
</button>
|
|
<button onclick={() => navigateMatch('next')} class="p-0.5 rounded {darkMode ? 'hover:bg-zinc-700' : 'hover:bg-gray-300'}" title="Next">
|
|
<ChevronDown class="w-3 h-3 {darkMode ? 'text-zinc-400' : 'text-gray-500'}" />
|
|
</button>
|
|
<button onclick={closeLogSearch} class="p-0.5 rounded {darkMode ? 'hover:bg-zinc-700' : 'hover:bg-gray-300'}" title="Close">
|
|
<X class="w-3 h-3 {darkMode ? 'text-zinc-400' : 'text-gray-500'}" />
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<button
|
|
onclick={toggleLogSearch}
|
|
class="p-1 rounded transition-colors {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-300'}"
|
|
title="Search logs"
|
|
>
|
|
<Search class="w-3 h-3 {darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
|
|
</button>
|
|
{/if}
|
|
<!-- Copy -->
|
|
<button
|
|
onclick={copyLogs}
|
|
class="p-1 rounded transition-colors {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-300'}"
|
|
title="Copy logs"
|
|
>
|
|
<Copy class="w-3 h-3 {darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
|
|
</button>
|
|
<!-- Download -->
|
|
<button
|
|
onclick={downloadLogs}
|
|
class="p-1 rounded transition-colors {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-300'}"
|
|
title="Download logs"
|
|
>
|
|
<Download class="w-3 h-3 {darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
|
|
</button>
|
|
<!-- Refresh -->
|
|
<button
|
|
onclick={fetchLogs}
|
|
class="p-1 rounded transition-colors {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-300'}"
|
|
title="Refresh logs"
|
|
>
|
|
<RefreshCw class="w-3 h-3 {darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
|
|
</button>
|
|
<!-- Close -->
|
|
{#if showCloseButton}
|
|
<button
|
|
onclick={handleClose}
|
|
class="p-1 rounded transition-colors {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-300'}"
|
|
title="Close logs"
|
|
>
|
|
<X class="w-3 h-3 {darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Logs content -->
|
|
<div bind:this={logsRef} class="flex-1 overflow-auto p-3">
|
|
{#if logs}
|
|
<pre class="logs-fade-in {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px; font-family: {terminalFontFamily()};">{@html highlightedLogs()}</pre>
|
|
{:else if loading}
|
|
<p class="text-xs {darkMode ? 'text-zinc-500' : 'text-gray-500'}">Connecting to log stream...</p>
|
|
{:else}
|
|
<p class="text-xs {darkMode ? 'text-zinc-500' : 'text-gray-500'}">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);
|
|
}
|
|
|
|
/* ANSI color classes - foreground colors */
|
|
:global(.ansi-black) { color: #3f3f46; }
|
|
:global(.ansi-red) { color: #ef4444; }
|
|
:global(.ansi-green) { color: #22c55e; }
|
|
:global(.ansi-yellow) { color: #eab308; }
|
|
:global(.ansi-blue) { color: #3b82f6; }
|
|
:global(.ansi-magenta) { color: #d946ef; }
|
|
:global(.ansi-cyan) { color: #06b6d4; }
|
|
:global(.ansi-white) { color: #e4e4e7; }
|
|
|
|
/* Bright foreground colors */
|
|
:global(.ansi-bright-black) { color: #71717a; }
|
|
:global(.ansi-bright-red) { color: #f87171; }
|
|
:global(.ansi-bright-green) { color: #4ade80; }
|
|
:global(.ansi-bright-yellow) { color: #facc15; }
|
|
:global(.ansi-bright-blue) { color: #60a5fa; }
|
|
:global(.ansi-bright-magenta) { color: #e879f9; }
|
|
:global(.ansi-bright-cyan) { color: #22d3ee; }
|
|
:global(.ansi-bright-white) { color: #fafafa; }
|
|
|
|
/* Background colors */
|
|
:global(.ansi-bg-black) { background-color: #18181b; }
|
|
:global(.ansi-bg-red) { background-color: #dc2626; }
|
|
:global(.ansi-bg-green) { background-color: #16a34a; }
|
|
:global(.ansi-bg-yellow) { background-color: #ca8a04; }
|
|
:global(.ansi-bg-blue) { background-color: #2563eb; }
|
|
:global(.ansi-bg-magenta) { background-color: #c026d3; }
|
|
:global(.ansi-bg-cyan) { background-color: #0891b2; }
|
|
:global(.ansi-bg-white) { background-color: #d4d4d8; }
|
|
|
|
/* Text styles */
|
|
:global(.ansi-bold) { font-weight: bold; }
|
|
:global(.ansi-dim) { opacity: 0.7; }
|
|
:global(.ansi-italic) { font-style: italic; }
|
|
:global(.ansi-underline) { text-decoration: underline; }
|
|
|
|
/* Fade-in animation for logs */
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
.logs-fade-in {
|
|
animation: fadeIn 0.3s ease-out;
|
|
}
|
|
</style>
|