diff --git a/.gitignore b/.gitignore index 2ffa176..e386e9c 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ sdk/go/slogx-demo # Rust sdk/rust/target/ + +# PHP +vendor/ +composer.lock diff --git a/App.tsx b/App.tsx index 4235bc3..0b4c262 100644 --- a/App.tsx +++ b/App.tsx @@ -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'; @@ -33,7 +33,7 @@ const loadSavedHidden = (): Set => { const App: FunctionComponent = () => { const [logs, setLogs] = useState([]); - const [selectedLog, setSelectedLog] = useState(null); + const { selected: selectedLog, setSelected: setSelectedLog, toggle: toggleLogSelection } = useToggleSelection(); const [filter, setFilter] = useState({ search: '', @@ -49,12 +49,7 @@ const App: FunctionComponent = () => { const [useMockData, setUseMockData] = useState(false); - const bottomRef = useRef(null); - const listContainerRef = useRef(null); - const splitContainerRef = useRef(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(() => { @@ -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); @@ -246,6 +159,25 @@ const App: FunctionComponent = () => { const hasConnections = Object.keys(connections).length - hiddenSources.size > 0; + const emptyState = (!useMockData && !hasConnections) ? ( +
+
+
+
+

No Active Data Sources

+

+ Connect to a backend service using the input above, or enable Demo to see sample data. +

+
+ ) : ( + <> + +

No logs found matching criteria

+ {logs.length > 0 &&

Try clearing filters

} + {hasConnections &&

Listening for events...

} + + ); + return (
{/* Header */} @@ -286,61 +218,12 @@ const App: FunctionComponent = () => {
- {/* Filter Bar */} -
-
- - setFilter(prev => ({...prev, search: (e.target as HTMLInputElement).value}))} - /> - {filter.search && ( - - )} -
- -
- -
- {[LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR].map(level => ( - - ))} -
- -
- -
- - - -
-
+ setLogs([])} + showPauseResume + /> {/* Main Content */}
@@ -348,63 +231,13 @@ const App: FunctionComponent = () => { className="log-list-container" style={{ height: selectedLog ? `${100 - panelHeight}%` : '100%' }} > -
- {filteredLogs.length === 0 ? ( -
- {(!useMockData && !hasConnections) ? ( -
-
-
-
-

No Active Data Sources

-

- Connect to a backend service using the input above, or enable Demo to see sample data. -

-
- ) : ( - <> - -

No logs found matching criteria

- {logs.length > 0 &&

Try clearing filters

} - {hasConnections &&

Listening for events...

} - - )} -
- ) : ( -
- {filteredLogs.map(log => ( - ( - toggleLogSelection(log)} - /> - )} - > - toggleLogSelection(log)} - /> - - ))} -
-
- )} -
- - {!autoScroll && logs.length > 0 && ( - - )} + 0} + />
{selectedLog && ( diff --git a/components/FilterBar.test.tsx b/components/FilterBar.test.tsx new file mode 100644 index 0000000..df53de7 --- /dev/null +++ b/components/FilterBar.test.tsx @@ -0,0 +1,211 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/preact'; +import FilterBar from './FilterBar'; +import { FilterState, LogLevel } from '../types'; + +const allLevels = new Set([LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR]); + +const makeFilter = (overrides: Partial = {}): FilterState => ({ + search: '', + levels: allLevels, + paused: false, + ...overrides +}); + +describe('FilterBar', () => { + it('renders all log level buttons', () => { + render( + {}} + onClear={() => {}} + /> + ); + + expect(screen.getByText('DEBUG')).toBeDefined(); + expect(screen.getByText('INFO')).toBeDefined(); + expect(screen.getByText('WARN')).toBeDefined(); + expect(screen.getByText('ERROR')).toBeDefined(); + }); + + it('toggles level filter when button clicked', () => { + const onFilterChange = vi.fn(); + const filter = makeFilter(); + + render( + {}} + /> + ); + + fireEvent.click(screen.getByText('DEBUG')); + + expect(onFilterChange).toHaveBeenCalledTimes(1); + const newFilter = onFilterChange.mock.calls[0][0]; + expect(newFilter.levels.has(LogLevel.DEBUG)).toBe(false); + expect(newFilter.levels.has(LogLevel.INFO)).toBe(true); + }); + + it('adds level back when toggled again', () => { + const onFilterChange = vi.fn(); + const filter = makeFilter({ + levels: new Set([LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR]) + }); + + render( + {}} + /> + ); + + fireEvent.click(screen.getByText('DEBUG')); + + expect(onFilterChange).toHaveBeenCalledTimes(1); + const newFilter = onFilterChange.mock.calls[0][0]; + expect(newFilter.levels.has(LogLevel.DEBUG)).toBe(true); + }); + + it('updates search on input', () => { + const onFilterChange = vi.fn(); + + render( + {}} + /> + ); + + const input = screen.getByPlaceholderText('Filter logs (msg, service, file)...'); + fireEvent.input(input, { target: { value: 'test query' } }); + + expect(onFilterChange).toHaveBeenCalledTimes(1); + expect(onFilterChange.mock.calls[0][0].search).toBe('test query'); + }); + + it('shows clear search button when search has value', () => { + render( + {}} + onClear={() => {}} + /> + ); + + expect(screen.getByRole('button', { name: '' })).toBeDefined(); + }); + + it('clears search when clear button clicked', () => { + const onFilterChange = vi.fn(); + + render( + {}} + /> + ); + + const clearButton = document.querySelector('.search-clear') as HTMLButtonElement; + fireEvent.click(clearButton); + + expect(onFilterChange).toHaveBeenCalledTimes(1); + expect(onFilterChange.mock.calls[0][0].search).toBe(''); + }); + + it('toggles pause state when pause button clicked', () => { + const onFilterChange = vi.fn(); + + render( + {}} + /> + ); + + fireEvent.click(screen.getByText('Pause')); + + expect(onFilterChange).toHaveBeenCalledTimes(1); + expect(onFilterChange.mock.calls[0][0].paused).toBe(true); + }); + + it('shows Resume when paused', () => { + render( + {}} + onClear={() => {}} + /> + ); + + expect(screen.getByText('Resume')).toBeDefined(); + expect(screen.queryByText('Pause')).toBeNull(); + }); + + it('calls onClear when clear button clicked', () => { + const onClear = vi.fn(); + + render( + {}} + onClear={onClear} + /> + ); + + fireEvent.click(screen.getByTitle('Clear Logs')); + + expect(onClear).toHaveBeenCalledTimes(1); + }); + + it('hides pause/resume when showPauseResume is false', () => { + render( + {}} + onClear={() => {}} + showPauseResume={false} + /> + ); + + expect(screen.queryByText('Pause')).toBeNull(); + expect(screen.queryByText('Resume')).toBeNull(); + }); + + it('shows pause/resume by default', () => { + render( + {}} + onClear={() => {}} + /> + ); + + expect(screen.getByText('Pause')).toBeDefined(); + }); + + it('applies active class to selected levels', () => { + const filter = makeFilter({ + levels: new Set([LogLevel.INFO, LogLevel.ERROR]) + }); + + render( + {}} + onClear={() => {}} + /> + ); + + const infoBtn = screen.getByText('INFO'); + const debugBtn = screen.getByText('DEBUG'); + + expect(infoBtn.className).toContain('active'); + expect(debugBtn.className).not.toContain('active'); + }); +}); diff --git a/components/FilterBar.tsx b/components/FilterBar.tsx new file mode 100644 index 0000000..482d613 --- /dev/null +++ b/components/FilterBar.tsx @@ -0,0 +1,90 @@ +import { FunctionComponent } from 'preact'; +import { Search, X, PlayCircle, PauseCircle, Trash2 } from 'lucide-preact'; +import { FilterState, LogLevel } from '../types'; + +interface FilterBarProps { + filter: FilterState; + onFilterChange: (filter: FilterState) => void; + onClear: () => void; + showPauseResume?: boolean; +} + +const FilterBar: FunctionComponent = ({ + filter, + onFilterChange, + onClear, + showPauseResume = true +}) => { + const toggleLevel = (level: LogLevel) => { + const newLevels = new Set(filter.levels); + if (newLevels.has(level)) newLevels.delete(level); + else newLevels.add(level); + onFilterChange({ ...filter, levels: newLevels }); + }; + + const handleSearch = (e: Event) => { + const val = (e.target as HTMLInputElement).value; + onFilterChange({ ...filter, search: val }); + }; + + return ( +
+
+ + + {filter.search && ( + + )} +
+ +
+ +
+ {[LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR].map(level => ( + + ))} +
+ +
+ +
+ {showPauseResume && ( + + )} + + +
+
+ ); +}; + +export default FilterBar; diff --git a/components/LogList.test.tsx b/components/LogList.test.tsx new file mode 100644 index 0000000..3dd1511 --- /dev/null +++ b/components/LogList.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/preact'; +import LogList from './LogList'; +import { LogEntry, LogLevel } from '../types'; + +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; +} + +const makeLog = (): LogEntry => ({ + id: '1', + timestamp: '2024-01-01T00:00:00.000Z', + level: LogLevel.INFO, + args: ['hello'], + metadata: {} +}); + +describe('LogList', () => { + it('respects scrollButtonCondition', () => { + const log = makeLog(); + + const { rerender, container } = render( + {}} + emptyState={
} + scrollButtonCondition={false} + /> + ); + + const list = container.querySelector('.log-list') as HTMLDivElement; + Object.defineProperty(list, 'scrollHeight', { value: 200, configurable: true }); + Object.defineProperty(list, 'clientHeight', { value: 100, configurable: true }); + Object.defineProperty(list, 'scrollTop', { value: 0, configurable: true }); + + fireEvent.scroll(list); + expect(screen.queryByTitle('Resume Auto-scroll')).toBeNull(); + + rerender( + {}} + emptyState={
} + scrollButtonCondition + /> + ); + + const list2 = container.querySelector('.log-list') as HTMLDivElement; + Object.defineProperty(list2, 'scrollHeight', { value: 200, configurable: true }); + Object.defineProperty(list2, 'clientHeight', { value: 100, configurable: true }); + Object.defineProperty(list2, 'scrollTop', { value: 0, configurable: true }); + + fireEvent.scroll(list2); + expect(screen.getByTitle('Resume Auto-scroll')).toBeDefined(); + }); +}); diff --git a/components/LogList.tsx b/components/LogList.tsx new file mode 100644 index 0000000..b8b76ed --- /dev/null +++ b/components/LogList.tsx @@ -0,0 +1,97 @@ +import { FunctionComponent, ComponentChildren } from 'preact'; +import { useRef, useEffect, useState } from 'preact/hooks'; +import { ArrowDown } from 'lucide-preact'; +import { LogEntry } from '../types'; +import LogItem, { ErrorLogItem } from './LogItem'; +import ErrorBoundary from './ErrorBoundary'; + +interface LogListProps { + logs: LogEntry[]; + selectedLog: LogEntry | null; + onSelectLog: (log: LogEntry) => void; + emptyState?: ComponentChildren; + scrollButtonCondition?: boolean; +} + +const LogList: FunctionComponent = ({ + logs, + selectedLog, + onSelectLog, + emptyState, + scrollButtonCondition +}) => { + const bottomRef = useRef(null); + const listContainerRef = useRef(null); + const [autoScroll, setAutoScroll] = useState(true); + + // Auto-scroll effect + useEffect(() => { + if (autoScroll && bottomRef.current && !selectedLog) { + bottomRef.current.scrollIntoView({ behavior: 'instant' }); + } + }, [logs, autoScroll, selectedLog]); + + const handleScroll = () => { + if (!listContainerRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = listContainerRef.current; + // slightly larger threshold + const isAtBottom = scrollHeight - scrollTop - clientHeight < 50; + setAutoScroll(isAtBottom); + }; + + const scrollToBottom = () => { + if (bottomRef.current) { + bottomRef.current.scrollIntoView({ behavior: 'smooth' }); + setAutoScroll(true); + } + }; + + const canShowScrollButton = scrollButtonCondition ?? logs.length > 0; + + return ( + <> +
+ {logs.length === 0 ? ( +
+ {emptyState} +
+ ) : ( +
+ {logs.map(log => ( + ( + onSelectLog(log)} + /> + )} + > + onSelectLog(log)} + /> + + ))} +
+
+ )} +
+ + {!autoScroll && canShowScrollButton && ( + + )} + + ); +}; + +export default LogList; diff --git a/hooks/useLogFilter.test.tsx b/hooks/useLogFilter.test.tsx new file mode 100644 index 0000000..2bbba43 --- /dev/null +++ b/hooks/useLogFilter.test.tsx @@ -0,0 +1,165 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/preact'; +import { useLogFilter } from './useLogFilter'; +import { LogEntry, LogLevel, FilterState } from '../types'; + +const makeLog = (overrides: Partial = {}): LogEntry => ({ + id: '1', + timestamp: '2024-01-01T00:00:00.000Z', + level: LogLevel.INFO, + args: ['hello world'], + metadata: {}, + ...overrides +}); + +const allLevels = new Set([LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR]); + +const makeFilter = (overrides: Partial = {}): FilterState => ({ + search: '', + levels: allLevels, + paused: false, + ...overrides +}); + +describe('useLogFilter', () => { + it('returns all logs when filter is empty', () => { + const logs = [ + makeLog({ id: '1' }), + makeLog({ id: '2' }), + makeLog({ id: '3' }) + ]; + const filter = makeFilter(); + + const { result } = renderHook(() => useLogFilter(logs, filter)); + + expect(result.current).toHaveLength(3); + }); + + it('filters by log level', () => { + const logs = [ + makeLog({ id: '1', level: LogLevel.DEBUG }), + makeLog({ id: '2', level: LogLevel.INFO }), + makeLog({ id: '3', level: LogLevel.WARN }), + makeLog({ id: '4', level: LogLevel.ERROR }) + ]; + const filter = makeFilter({ levels: new Set([LogLevel.WARN, LogLevel.ERROR]) }); + + const { result } = renderHook(() => useLogFilter(logs, filter)); + + expect(result.current).toHaveLength(2); + expect(result.current.map(l => l.id)).toEqual(['3', '4']); + }); + + it('filters by search text in args', () => { + const logs = [ + makeLog({ id: '1', args: ['user logged in'] }), + makeLog({ id: '2', args: ['database error'] }), + makeLog({ id: '3', args: ['user logged out'] }) + ]; + const filter = makeFilter({ search: 'user' }); + + const { result } = renderHook(() => useLogFilter(logs, filter)); + + expect(result.current).toHaveLength(2); + expect(result.current.map(l => l.id)).toEqual(['1', '3']); + }); + + it('filters by search text in metadata file', () => { + const logs = [ + makeLog({ id: '1', metadata: { file: 'auth.ts' } }), + makeLog({ id: '2', metadata: { file: 'database.ts' } }), + makeLog({ id: '3', metadata: { file: 'auth-utils.ts' } }) + ]; + const filter = makeFilter({ search: 'auth' }); + + const { result } = renderHook(() => useLogFilter(logs, filter)); + + expect(result.current).toHaveLength(2); + expect(result.current.map(l => l.id)).toEqual(['1', '3']); + }); + + it('filters by search text in metadata service', () => { + const logs = [ + makeLog({ id: '1', metadata: { service: 'auth-service' } }), + makeLog({ id: '2', metadata: { service: 'payment-service' } }), + makeLog({ id: '3', metadata: { service: 'auth-worker' } }) + ]; + const filter = makeFilter({ search: 'auth' }); + + const { result } = renderHook(() => useLogFilter(logs, filter)); + + expect(result.current).toHaveLength(2); + expect(result.current.map(l => l.id)).toEqual(['1', '3']); + }); + + it('search is case insensitive', () => { + const logs = [ + makeLog({ id: '1', args: ['USER logged in'] }), + makeLog({ id: '2', args: ['database error'] }) + ]; + const filter = makeFilter({ search: 'user' }); + + const { result } = renderHook(() => useLogFilter(logs, filter)); + + expect(result.current).toHaveLength(1); + expect(result.current[0].id).toBe('1'); + }); + + it('filters out hidden sources', () => { + const logs = [ + makeLog({ id: '1', source: 'ws://localhost:8080' }), + makeLog({ id: '2', source: 'ws://localhost:8081' }), + makeLog({ id: '3', source: 'ws://localhost:8080' }) + ]; + const filter = makeFilter(); + const hiddenSources = new Set(['ws://localhost:8080']); + + const { result } = renderHook(() => useLogFilter(logs, filter, hiddenSources)); + + expect(result.current).toHaveLength(1); + expect(result.current[0].id).toBe('2'); + }); + + it('combines level and search filters', () => { + const logs = [ + makeLog({ id: '1', level: LogLevel.DEBUG, args: ['user debug'] }), + makeLog({ id: '2', level: LogLevel.INFO, args: ['user info'] }), + makeLog({ id: '3', level: LogLevel.ERROR, args: ['user error'] }), + makeLog({ id: '4', level: LogLevel.INFO, args: ['other info'] }) + ]; + const filter = makeFilter({ + levels: new Set([LogLevel.INFO, LogLevel.ERROR]), + search: 'user' + }); + + const { result } = renderHook(() => useLogFilter(logs, filter)); + + expect(result.current).toHaveLength(2); + expect(result.current.map(l => l.id)).toEqual(['2', '3']); + }); + + it('searches in nested object args', () => { + const logs = [ + makeLog({ id: '1', args: [{ user: { name: 'alice' } }] }), + makeLog({ id: '2', args: [{ user: { name: 'bob' } }] }) + ]; + const filter = makeFilter({ search: 'alice' }); + + const { result } = renderHook(() => useLogFilter(logs, filter)); + + expect(result.current).toHaveLength(1); + expect(result.current[0].id).toBe('1'); + }); + + it('returns empty array when all logs filtered out', () => { + const logs = [ + makeLog({ id: '1', level: LogLevel.DEBUG }), + makeLog({ id: '2', level: LogLevel.DEBUG }) + ]; + const filter = makeFilter({ levels: new Set([LogLevel.ERROR]) }); + + const { result } = renderHook(() => useLogFilter(logs, filter)); + + expect(result.current).toHaveLength(0); + }); +}); diff --git a/hooks/useLogFilter.ts b/hooks/useLogFilter.ts new file mode 100644 index 0000000..c83518c --- /dev/null +++ b/hooks/useLogFilter.ts @@ -0,0 +1,35 @@ +import { useMemo } from 'preact/hooks'; +import { LogEntry, FilterState } from '../types'; + +export const useLogFilter = ( + logs: LogEntry[], + filter: FilterState, + hiddenSources: Set = new Set() +): LogEntry[] => { + return useMemo(() => { + return logs.filter(log => { + // Filter out hidden sources + if (log.source && hiddenSources.has(log.source)) return false; + + // Filter by log level + if (!filter.levels.has(log.level)) return false; + + // Filter by search text + if (filter.search) { + const searchLower = filter.search.toLowerCase(); + + // Search in arguments (message content) + const contentMatch = log.args.some(arg => + JSON.stringify(arg).toLowerCase().includes(searchLower) + ); + + // Search in metadata (filename, service name) + const metaMatch = log.metadata.file?.toLowerCase().includes(searchLower) + || log.metadata.service?.toLowerCase().includes(searchLower); + + return contentMatch || metaMatch; + } + return true; + }); + }, [logs, filter, hiddenSources]); +}; diff --git a/hooks/useSplitPane.test.tsx b/hooks/useSplitPane.test.tsx new file mode 100644 index 0000000..775c7ad --- /dev/null +++ b/hooks/useSplitPane.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/preact'; +import { useSplitPane } from './useSplitPane'; + +const TestComponent = () => { + const { splitContainerRef, panelHeight, startResize } = useSplitPane({ + initialPercent: 50, + minPercent: 20, + maxPercent: 80 + }); + + return ( +
+
+
{panelHeight}
+
+
+ ); +}; + +describe('useSplitPane', () => { + it('clamps resize percentage within bounds', async () => { + render(); + + const container = screen.getByTestId('container'); + Object.defineProperty(container, 'getBoundingClientRect', { + value: () => ({ + top: 0, + height: 100, + bottom: 100, + left: 0, + right: 100, + width: 100, + x: 0, + y: 0, + toJSON: () => {} + }) + }); + + fireEvent.mouseDown(screen.getByTestId('divider')); + + await waitFor(() => { + expect(document.body.style.cursor).toBe('row-resize'); + }); + + fireEvent.mouseMove(document, { clientY: 90 }); + await waitFor(() => { + expect(screen.getByTestId('height').textContent).toBe('20'); + }); + + fireEvent.mouseMove(document, { clientY: 10 }); + await waitFor(() => { + expect(screen.getByTestId('height').textContent).toBe('80'); + }); + + fireEvent.mouseUp(document); + await waitFor(() => { + expect(document.body.style.cursor).toBe('default'); + }); + }); +}); diff --git a/hooks/useSplitPane.ts b/hooks/useSplitPane.ts new file mode 100644 index 0000000..bb18c96 --- /dev/null +++ b/hooks/useSplitPane.ts @@ -0,0 +1,53 @@ +import { useEffect, useRef, useState } from 'preact/hooks'; + +interface SplitPaneOptions { + initialPercent?: number; + minPercent?: number; + maxPercent?: number; +} + +export const useSplitPane = (options: SplitPaneOptions = {}) => { + const { + initialPercent = 50, + minPercent = 20, + maxPercent = 80 + } = options; + + const splitContainerRef = useRef(null); + const [panelHeight, setPanelHeight] = useState(initialPercent); + const [isDragging, setIsDragging] = useState(false); + + 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(minPercent, Math.min(maxPercent, 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, minPercent, maxPercent]); + + const startResize = (e: MouseEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + return { splitContainerRef, panelHeight, startResize }; +}; diff --git a/hooks/useToggleSelection.test.tsx b/hooks/useToggleSelection.test.tsx new file mode 100644 index 0000000..1f17a66 --- /dev/null +++ b/hooks/useToggleSelection.test.tsx @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/preact'; +import { useToggleSelection } from './useToggleSelection'; + +interface Item { + id: string; + label: string; +} + +const TestComponent = () => { + const { selected, toggle } = useToggleSelection(); + const itemA = { id: 'a', label: 'A' }; + const itemB = { id: 'b', label: 'B' }; + + return ( +
+
{selected?.id ?? 'none'}
+ + +
+ ); +}; + +describe('useToggleSelection', () => { + it('toggles selection on repeated clicks', () => { + render(); + + const selected = screen.getByTestId('selected'); + expect(selected.textContent).toBe('none'); + + fireEvent.click(screen.getByText('toggle-a')); + expect(selected.textContent).toBe('a'); + + fireEvent.click(screen.getByText('toggle-a')); + expect(selected.textContent).toBe('none'); + + fireEvent.click(screen.getByText('toggle-b')); + expect(selected.textContent).toBe('b'); + }); +}); diff --git a/hooks/useToggleSelection.ts b/hooks/useToggleSelection.ts new file mode 100644 index 0000000..d82a973 --- /dev/null +++ b/hooks/useToggleSelection.ts @@ -0,0 +1,11 @@ +import { useState } from 'preact/hooks'; + +export const useToggleSelection = () => { + const [selected, setSelected] = useState(null); + + const toggle = (item: T) => { + setSelected(prev => (prev?.id === item.id ? null : item)); + }; + + return { selected, setSelected, toggle }; +}; diff --git a/sdk/python/README.md b/sdk/python/README.md index 84b36e8..076849e 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -28,6 +28,26 @@ slogx.warn("Warning message") slogx.error("Error occurred", {"code": 500}) ``` +## CI Mode + +In CI environments, you can write logs to a file instead of starting a WebSocket server. +CI mode is auto-detected via common CI env vars, or can be forced explicitly. + +```python +import os +from slogx import slogx + +slogx.init( + is_dev=True, + service_name='my-service', + ci_mode=True, + log_file_path='./slogx_logs/my-service.ndjson', + max_entries=10000 +) + +slogx.info("CI log entry", {"ok": True}) +``` + ## Features - WebSocket-based real-time log streaming diff --git a/sdk/python/slogx.py b/sdk/python/slogx.py index b7d364a..bcd8a2f 100644 --- a/sdk/python/slogx.py +++ b/sdk/python/slogx.py @@ -1,11 +1,12 @@ +import atexit import asyncio import json +import os import traceback import inspect import random import string import threading -import time from datetime import datetime from enum import Enum from typing import Any, Optional, Set @@ -19,20 +20,145 @@ class LogLevel(Enum): ERROR = 'ERROR' +CI_ENV_VARS = [ + 'CI', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'JENKINS_HOME', + 'CIRCLECI', + 'BUILDKITE', + 'TF_BUILD', + 'TRAVIS' +] + + +def _detect_ci() -> bool: + return any(os.environ.get(var) for var in CI_ENV_VARS) + + +class CIWriter: + """Write log entries to a file in NDJSON format with a rolling window.""" + + def __init__(self, file_path: str, max_entries: int = 10000): + if max_entries <= 0: + max_entries = 10000 + + self._file_path = file_path + self._max_entries = max_entries + self._buffer = [] + self._buffer_lock = threading.Lock() + self._entry_count = 0 + self._closed = False + self._stop_event = threading.Event() + + dir_path = os.path.dirname(file_path) or '.' + os.makedirs(dir_path, exist_ok=True) + + # Clear existing file (fresh run) + with open(self._file_path, 'w', encoding='utf-8') as f: + f.write('') + + self._flush_thread = threading.Thread(target=self._flush_loop, daemon=True) + self._flush_thread.start() + + atexit.register(self.close) + + def _flush_loop(self): + while not self._stop_event.wait(0.5): + self.flush() + + def write(self, entry: Any): + with self._buffer_lock: + if self._closed: + return + + try: + line = json.dumps(entry, default=str) + except Exception: + return + + self._buffer.append(line) + self._entry_count += 1 + should_flush = len(self._buffer) > int(self._max_entries * 1.5) + + if should_flush: + self.flush() + + def flush(self): + with self._buffer_lock: + if not self._buffer: + return + content = '\n'.join(self._buffer) + '\n' + self._buffer = [] + + try: + with open(self._file_path, 'a', encoding='utf-8') as f: + f.write(content) + except Exception: + return + + self._enforce_rolling_window() + + def _enforce_rolling_window(self): + try: + with open(self._file_path, 'r', encoding='utf-8') as f: + lines = [line.rstrip('\n') for line in f if line.strip()] + except Exception: + return + + if len(lines) <= self._max_entries: + return + + trimmed = lines[-self._max_entries:] + try: + with open(self._file_path, 'w', encoding='utf-8') as f: + f.write('\n'.join(trimmed) + '\n') + except Exception: + return + + def close(self): + with self._buffer_lock: + if self._closed: + return + self._closed = True + + self._stop_event.set() + if self._flush_thread.is_alive(): + self._flush_thread.join(timeout=1) + + self.flush() + + def get_entry_count(self) -> int: + return self._entry_count + + class SlogX: def __init__(self): self._clients: Set[WebSocketServerProtocol] = set() self._service_name: str = 'python-service' self._server = None self._loop: Optional[asyncio.AbstractEventLoop] = None + self._ci_writer: Optional[CIWriter] = None + self._initialized: bool = False - def init(self, is_dev: bool, port: int = 8080, service_name: str = 'python-service'): + def init( + self, + is_dev: bool, + port: int = 8080, + service_name: str = 'python-service', + ci_mode: Optional[bool] = None, + log_file_path: Optional[str] = None, + max_entries: int = 10000 + ): """Initialize the SlogX server. Starts a WebSocket server on the specified port. Args: is_dev: Required. Must be True to enable slogx. Prevents accidental production use. port: WebSocket server port (default 8080) service_name: Service name for log metadata + ci_mode: Optional. None = auto-detect, True = force CI mode, False = force WebSocket mode + log_file_path: Optional. Log file path for CI mode + max_entries: Optional. Max log entries to keep for CI mode Blocks until the server is ready to accept connections. """ @@ -40,6 +166,15 @@ def init(self, is_dev: bool, port: int = 8080, service_name: str = 'python-servi return self._service_name = service_name + use_ci = ci_mode if ci_mode is not None else _detect_ci() + + if use_ci: + file_path = log_file_path or f"./slogx_logs/{self._service_name}.ndjson" + self._ci_writer = CIWriter(file_path, max_entries) + self._initialized = True + print(f"[slogx] 📝 CI mode: logging to {file_path}") + return + self._loop = asyncio.new_event_loop() ready_event = threading.Event() @@ -63,6 +198,7 @@ def run_loop(): thread = threading.Thread(target=run_loop, daemon=True) thread.start() ready_event.wait() # Block until server is ready + self._initialized = True def _generate_id(self) -> str: return ''.join(random.choices(string.ascii_lowercase + string.digits, k=13)) @@ -89,6 +225,40 @@ def _get_caller_info(self) -> dict: def _log(self, level: LogLevel, *args: Any): """Core logging function.""" + if self._ci_writer: + caller = self._get_caller_info() + processed_args = [] + final_stack = caller.get('clean_stack') + + for arg in args: + if isinstance(arg, Exception): + final_stack = ''.join(traceback.format_exception(type(arg), arg, arg.__traceback__)) + processed_args.append({ + 'name': type(arg).__name__, + 'message': str(arg), + 'stack': final_stack + }) + else: + processed_args.append(arg) + + entry = { + 'id': self._generate_id(), + 'timestamp': datetime.utcnow().isoformat() + 'Z', + 'level': level.value, + 'args': processed_args, + 'stacktrace': final_stack, + 'metadata': { + 'file': caller.get('file'), + 'line': caller.get('line'), + 'func': caller.get('func'), + 'lang': 'python', + 'service': self._service_name + } + } + + self._ci_writer.write(entry) + return + if not self._loop or not self._clients: return @@ -134,6 +304,15 @@ async def broadcast(): if self._loop and self._loop.is_running(): asyncio.run_coroutine_threadsafe(broadcast(), self._loop) + def close(self): + if self._ci_writer: + self._ci_writer.close() + self._ci_writer = None + if self._server: + self._server.close() + self._server = None + self._initialized = False + def debug(self, *args: Any): self._log(LogLevel.DEBUG, *args) diff --git a/sdk/python/test_ci_writer.py b/sdk/python/test_ci_writer.py new file mode 100644 index 0000000..b439161 --- /dev/null +++ b/sdk/python/test_ci_writer.py @@ -0,0 +1,91 @@ +import os +import json +import time +import shutil +import tempfile +import threading +from unittest.mock import patch +from slogx import CIWriter + +def test_ci_writer_creates_directory(): + tmp_dir = tempfile.mkdtemp() + try: + log_path = os.path.join(tmp_dir, "nested", "dir", "test.ndjson") + writer = CIWriter(log_path) + writer.close() + + assert os.path.exists(log_path) + finally: + shutil.rmtree(tmp_dir) + +def test_ci_writer_writes_and_flushes(): + tmp_fd, log_path = tempfile.mkstemp() + os.close(tmp_fd) + try: + writer = CIWriter(log_path, max_entries=100) + writer.write({"msg": "hello"}) + writer.write({"msg": "world"}) + writer.flush() + + with open(log_path, 'r') as f: + lines = f.readlines() + + assert len(lines) == 2 + assert json.loads(lines[0])['msg'] == 'hello' + assert json.loads(lines[1])['msg'] == 'world' + finally: + if os.path.exists(log_path): + os.remove(log_path) + +def test_rolling_window(): + tmp_fd, log_path = tempfile.mkstemp() + os.close(tmp_fd) + try: + max_entries = 5 + writer = CIWriter(log_path, max_entries=max_entries) + + # Write 10 entries + for i in range(10): + writer.write({"i": i}) + + writer.flush() + + with open(log_path, 'r') as f: + lines = f.readlines() + + # Should contain ONLY the last 5 entries + assert len(lines) == max_entries + + # Verify first remaining entry is index 5 + first = json.loads(lines[0]) + assert first['i'] == 5 + + # Verify last is 9 + last = json.loads(lines[-1]) + assert last['i'] == 9 + + finally: + if os.path.exists(log_path): + os.remove(log_path) + +def test_auto_flush_on_overflow(): + tmp_fd, log_path = tempfile.mkstemp() + os.close(tmp_fd) + try: + # max 2, so buffer trigger is > 3 (1.5 * 2 = 3) + writer = CIWriter(log_path, max_entries=2) + + writer.write({"val": 1}) + writer.write({"val": 2}) + writer.write({"val": 3}) + writer.write({"val": 4}) + # Should trigger flush synchronously in _flush_unsafe call inside write + + with open(log_path, 'r') as f: + content = f.read() + + assert len(content) > 0 + assert "val" in content + finally: + if os.path.exists(log_path): + os.remove(log_path) diff --git a/sdk/python/test_slogx.py b/sdk/python/test_slogx.py index c23a2a0..5218307 100644 --- a/sdk/python/test_slogx.py +++ b/sdk/python/test_slogx.py @@ -1,10 +1,12 @@ """Unit tests for the slogx Python SDK.""" +import os import pytest import json +import tempfile from datetime import datetime from unittest.mock import MagicMock, patch -from slogx import SlogX, LogLevel +from slogx import SlogX, LogLevel, _detect_ci, CI_ENV_VARS class TestLogLevel: @@ -244,5 +246,66 @@ def __str__(self): assert 'CustomClass instance' in json_str +class TestCIMode: + def test_detect_ci_env_var(self, monkeypatch): + # Clear all CI env vars first (we might be running in CI) + for var in CI_ENV_VARS: + monkeypatch.delenv(var, raising=False) + assert _detect_ci() is False + + monkeypatch.setenv('CI', 'true') + assert _detect_ci() is True + + monkeypatch.delenv('CI', raising=False) + assert _detect_ci() is False + + def test_init_ci_mode_forced(self): + s = SlogX() + with tempfile.TemporaryDirectory() as tmp_dir: + log_path = os.path.join(tmp_dir, 'logs', 'test.ndjson') + s.init( + is_dev=True, + service_name='ci-service', + ci_mode=True, + log_file_path=log_path, + max_entries=10 + ) + + assert s._ci_writer is not None + assert s._loop is None + + s.info('hello', {'ok': True}) + s._ci_writer.flush() + + with open(log_path, 'r') as f: + lines = [line for line in f if line.strip()] + + assert len(lines) >= 1 + entry = json.loads(lines[-1]) + assert entry['metadata']['service'] == 'ci-service' + assert entry['metadata']['lang'] == 'python' + + s.close() + + def test_init_ci_mode_auto_detect(self, monkeypatch): + s = SlogX() + with tempfile.TemporaryDirectory() as tmp_dir: + log_path = os.path.join(tmp_dir, 'logs', 'auto.ndjson') + monkeypatch.setenv('CI', 'true') + s.init( + is_dev=True, + service_name='auto-ci', + ci_mode=None, + log_file_path=log_path + ) + + assert s._ci_writer is not None + s.info('auto') + s._ci_writer.flush() + assert os.path.exists(log_path) + + s.close() + + if __name__ == '__main__': pytest.main([__file__, '-v']) diff --git a/vite.config.ts b/vite.config.ts index 2f7e51b..b035061 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -38,6 +38,6 @@ export default defineConfig({ '**/*.spec.ts', ] }, - include: ['components/*.test.{ts,tsx}'], + include: ['components/*.test.{ts,tsx}', 'hooks/*.test.{ts,tsx}'], } });