Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,7 @@ sdk/go/slogx-demo

# Rust
sdk/rust/target/

# PHP
vendor/
composer.lock
251 changes: 42 additions & 209 deletions App.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
import { useState, useEffect, useRef } from 'preact/hooks';
import type { FunctionComponent } from 'preact';
import { LogEntry, LogLevel, FilterState } from './types';
import LogItem, { ErrorLogItem } from './components/LogItem';
import ErrorBoundary from './components/ErrorBoundary';
import LogDetailsPanel from './components/LogDetailsPanel';
import SetupModal from './components/SetupModal';
import ConnectionManager, { ConnectionStatus } from './components/ConnectionManager';
import FilterBar from './components/FilterBar';
import LogList from './components/LogList';
import { useLogFilter } from './hooks/useLogFilter';
import { useSplitPane } from './hooks/useSplitPane';
import { useToggleSelection } from './hooks/useToggleSelection';
import { generateMockLog } from './services/mockService';
import { connectToLogStream } from './services/api';
import {
PauseCircle, PlayCircle, Trash2, Search, Filter,
Settings, RefreshCw, ArrowDown, X
} from 'lucide-preact';
import { Filter, Settings, RefreshCw } from 'lucide-preact';

const MAX_LOGS = 2000;
const STORAGE_KEY_SERVERS = 'slogx:servers';
Expand All @@ -33,7 +33,7 @@ const loadSavedHidden = (): Set<string> => {

const App: FunctionComponent = () => {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
const { selected: selectedLog, setSelected: setSelectedLog, toggle: toggleLogSelection } = useToggleSelection<LogEntry>();

const [filter, setFilter] = useState<FilterState>({
search: '',
Expand All @@ -49,12 +49,7 @@ const App: FunctionComponent = () => {

const [useMockData, setUseMockData] = useState(false);

const bottomRef = useRef<HTMLDivElement>(null);
const listContainerRef = useRef<HTMLDivElement>(null);
const splitContainerRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
const [panelHeight, setPanelHeight] = useState(50);
const [isDragging, setIsDragging] = useState(false);
const { splitContainerRef, panelHeight, startResize } = useSplitPane();
const [showSetup, setShowSetup] = useState(false);

useEffect(() => {
Expand Down Expand Up @@ -153,89 +148,7 @@ const App: FunctionComponent = () => {
return () => clearInterval(mockInterval);
}, [useMockData, filter.paused]);

useEffect(() => {
if (autoScroll && bottomRef.current && !selectedLog) {
bottomRef.current.scrollIntoView({ behavior: 'instant' });
}
}, [logs, autoScroll, selectedLog]);

useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging || !splitContainerRef.current) return;
const containerRect = splitContainerRef.current.getBoundingClientRect();
const relativeY = e.clientY - containerRect.top;
const newPercentage = ((containerRect.height - relativeY) / containerRect.height) * 100;
setPanelHeight(Math.max(20, Math.min(80, newPercentage)));
};

const handleMouseUp = () => {
setIsDragging(false);
document.body.style.cursor = 'default';
};

if (isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'row-resize';
}

return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'default';
};
}, [isDragging]);

const startResize = (e: MouseEvent) => {
e.preventDefault();
setIsDragging(true);
};

const handleScroll = () => {
if (!listContainerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = listContainerRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
setAutoScroll(isAtBottom);
};

const scrollToBottom = () => {
if (bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'smooth' });
setAutoScroll(true);
}
};

const filteredLogs = useMemo(() => {
return logs.filter(log => {
if (log.source && hiddenSources.has(log.source)) return false;
if (!filter.levels.has(log.level)) return false;

if (filter.search) {
const searchLower = filter.search.toLowerCase();
const contentMatch = log.args.some(arg =>
JSON.stringify(arg).toLowerCase().includes(searchLower)
);
const metaMatch = log.metadata.file?.toLowerCase().includes(searchLower)
|| log.metadata.service?.toLowerCase().includes(searchLower);
return contentMatch || metaMatch;
}
return true;
});
}, [logs, filter, hiddenSources]);

const toggleLevel = (level: LogLevel) => {
setFilter(prev => {
const newLevels = new Set(prev.levels);
if (newLevels.has(level)) newLevels.delete(level);
else newLevels.add(level);
return { ...prev, levels: newLevels };
});
};

const toggleLogSelection = (log: LogEntry) => {
if (selectedLog?.id === log.id) setSelectedLog(null);
else setSelectedLog(log);
};
const filteredLogs = useLogFilter(logs, filter, hiddenSources);

const handleMockToggle = () => {
setUseMockData(!useMockData);
Expand All @@ -246,6 +159,25 @@ const App: FunctionComponent = () => {

const hasConnections = Object.keys(connections).length - hiddenSources.size > 0;

const emptyState = (!useMockData && !hasConnections) ? (
<div className="empty-state-card">
<div className="empty-state-icon">
<div><Filter size={24} /></div>
</div>
<h3>No Active Data Sources</h3>
<p>
Connect to a backend service using the input above, or enable <strong>Demo</strong> to see sample data.
</p>
</div>
) : (
<>
<Filter size={48} style={{ marginBottom: 16, opacity: 0.2 }} />
<p>No logs found matching criteria</p>
{logs.length > 0 && <p style={{ fontSize: 12, marginTop: 8, opacity: 0.5 }}>Try clearing filters</p>}
{hasConnections && <p className="animate-pulse" style={{ fontSize: 12, marginTop: 16, color: 'var(--emerald-500)' }}>Listening for events...</p>}
</>
);

return (
<div className="app">
{/* Header */}
Expand Down Expand Up @@ -286,125 +218,26 @@ const App: FunctionComponent = () => {
</div>
</header>

{/* Filter Bar */}
<div className="filter-bar">
<div className="search-box">
<Search size={14} />
<input
type="text"
placeholder="Filter logs (msg, service, file)..."
value={filter.search}
onInput={(e) => setFilter(prev => ({...prev, search: (e.target as HTMLInputElement).value}))}
/>
{filter.search && (
<button
type="button"
className="search-clear"
onClick={() => setFilter(prev => ({...prev, search: ''}))}
>
<X size={14} />
</button>
)}
</div>

<div className="filter-divider"></div>

<div className="level-filters">
{[LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR].map(level => (
<button
key={level}
onClick={() => toggleLevel(level)}
className={`level-btn ${filter.levels.has(level) ? `active ${level.toLowerCase()}` : ''}`}
>
{level}
</button>
))}
</div>

<div className="filter-spacer"></div>

<div className="controls">
<button
onClick={() => setFilter(prev => ({ ...prev, paused: !prev.paused }))}
className={`btn-pause ${filter.paused ? 'paused' : ''}`}
>
{filter.paused ? <PlayCircle size={14} /> : <PauseCircle size={14} />}
{filter.paused ? 'Resume' : 'Pause'}
</button>

<button
onClick={() => setLogs([])}
className="btn-icon danger"
title="Clear Logs"
>
<Trash2 size={16} />
</button>
</div>
</div>
<FilterBar
filter={filter}
onFilterChange={setFilter}
onClear={() => setLogs([])}
showPauseResume
/>

{/* Main Content */}
<div className="main-content" ref={splitContainerRef}>
<div
className="log-list-container"
style={{ height: selectedLog ? `${100 - panelHeight}%` : '100%' }}
>
<div
ref={listContainerRef}
onScroll={handleScroll}
className="log-list"
>
{filteredLogs.length === 0 ? (
<div className="empty-state">
{(!useMockData && !hasConnections) ? (
<div className="empty-state-card">
<div className="empty-state-icon">
<div><Filter size={24} /></div>
</div>
<h3>No Active Data Sources</h3>
<p>
Connect to a backend service using the input above, or enable <strong>Demo</strong> to see sample data.
</p>
</div>
) : (
<>
<Filter size={48} style={{ marginBottom: 16, opacity: 0.2 }} />
<p>No logs found matching criteria</p>
{logs.length > 0 && <p style={{ fontSize: 12, marginTop: 8, opacity: 0.5 }}>Try clearing filters</p>}
{hasConnections && <p className="animate-pulse" style={{ fontSize: 12, marginTop: 16, color: 'var(--emerald-500)' }}>Listening for events...</p>}
</>
)}
</div>
) : (
<div style={{ paddingBottom: 8 }}>
{filteredLogs.map(log => (
<ErrorBoundary
key={log.id}
fallback={(error) => (
<ErrorLogItem
log={log}
error={error}
selected={selectedLog?.id === log.id}
onSelect={() => toggleLogSelection(log)}
/>
)}
>
<LogItem
log={log}
selected={selectedLog?.id === log.id}
onSelect={() => toggleLogSelection(log)}
/>
</ErrorBoundary>
))}
<div ref={bottomRef} />
</div>
)}
</div>

{!autoScroll && logs.length > 0 && (
<button onClick={scrollToBottom} className="scroll-btn" title="Resume Auto-scroll">
<ArrowDown size={18} />
</button>
)}
<LogList
logs={filteredLogs}
selectedLog={selectedLog}
onSelectLog={toggleLogSelection}
emptyState={emptyState}
scrollButtonCondition={logs.length > 0}
/>
</div>

{selectedLog && (
Expand Down
Loading