diff --git a/src/web/frontend/src/App.tsx b/src/web/frontend/src/App.tsx index 4ad340c..8f8def7 100644 --- a/src/web/frontend/src/App.tsx +++ b/src/web/frontend/src/App.tsx @@ -4,6 +4,7 @@ import WorkerGrid from './components/WorkerGrid'; import ActivityStream from './components/ActivityStream'; import WorkerDetail from './components/WorkerDetail'; import CollisionAlert from './components/CollisionAlert'; +import FileHeatmap from './components/FileHeatmap'; const App: React.FC = () => { const [workers, setWorkers] = useState([]); @@ -12,6 +13,7 @@ const App: React.FC = () => { const [connected, setConnected] = useState(false); const [collisionAlerts, setCollisionAlerts] = useState([]); const [showCollisionPanel, setShowCollisionPanel] = useState(false); + const [showFileHeatmap, setShowFileHeatmap] = useState(false); const handleWebSocketMessage = useCallback((message: WebSocketMessage) => { if (message.type === 'init') { @@ -116,6 +118,14 @@ const App: React.FC = () => {

FABRIC

+ {unacknowledgedAlertCount > 0 && (
); diff --git a/src/web/frontend/src/components/FileHeatmap.tsx b/src/web/frontend/src/components/FileHeatmap.tsx new file mode 100644 index 0000000..ce69e5a --- /dev/null +++ b/src/web/frontend/src/components/FileHeatmap.tsx @@ -0,0 +1,319 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + FileHeatmapEntry, + FileHeatmapStats, + HeatLevel, + HeatmapSortMode, +} from '../types'; + +interface FileHeatmapProps { + visible: boolean; + onClose: () => void; +} + +const FileHeatmap: React.FC = ({ visible, onClose }) => { + const [entries, setEntries] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [sortMode, setSortMode] = useState('modifications'); + const [showCollisionsOnly, setShowCollisionsOnly] = useState(false); + const [selectedEntry, setSelectedEntry] = useState(null); + const [filter, setFilter] = useState(''); + + const fetchHeatmap = useCallback(async () => { + try { + setLoading(true); + const params = new URLSearchParams({ + sortBy: sortMode, + collisionsOnly: String(showCollisionsOnly), + ...(filter && { directoryFilter: filter }), + }); + + const [entriesRes, statsRes] = await Promise.all([ + fetch(`/api/heatmap?${params}`), + fetch('/api/heatmap/stats'), + ]); + + if (!entriesRes.ok || !statsRes.ok) { + throw new Error('Failed to fetch heatmap data'); + } + + const entriesData = await entriesRes.json(); + const statsData = await statsRes.json(); + + setEntries(entriesData); + setStats(statsData); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, [sortMode, showCollisionsOnly, filter]); + + useEffect(() => { + if (visible) { + fetchHeatmap(); + } + }, [visible, fetchHeatmap]); + + const getHeatColor = (level: HeatLevel): string => { + switch (level) { + case 'cold': return '#4fc3f7'; + case 'warm': return '#ffb74d'; + case 'hot': return '#f06292'; + case 'critical': return '#e53935'; + } + }; + + const getHeatIcon = (level: HeatLevel): string => { + switch (level) { + case 'cold': return '\u25cb'; + case 'warm': return '\u25d0'; + case 'hot': return '\u25cf'; + case 'critical': return '\ud83d\udd25'; + } + }; + + const getHeatBar = (level: HeatLevel, modifications: number): number => { + const maxBars = 10; + let bars: number; + + switch (level) { + case 'cold': bars = Math.min(2, modifications); break; + case 'warm': bars = Math.min(4, Math.floor(modifications / 2) + 2); break; + case 'hot': bars = Math.min(7, Math.floor(modifications / 2) + 4); break; + case 'critical': bars = Math.min(10, Math.floor(modifications / 2) + 6); break; + } + + return Math.min(bars, maxBars); + }; + + const formatPath = (path: string, maxLength: number = 40): string => { + if (path.length <= maxLength) return path; + + const fileName = path.substring(path.lastIndexOf('/') + 1); + const dir = path.substring(0, path.lastIndexOf('/')); + + if (fileName.length >= maxLength - 3) { + return '...' + fileName.substring(0, maxLength - 3); + } + + const available = maxLength - fileName.length - 4; + if (available > 0 && dir.length > available) { + return dir.substring(0, available) + '.../' + fileName; + } + + return '...' + path.substring(path.length - maxLength + 3); + }; + + const formatTime = (timestamp: number): string => { + return new Date(timestamp).toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + }); + }; + + const formatWorkers = (workers: FileHeatmapEntry['workers']): string => { + if (workers.length === 0) return '-'; + if (workers.length === 1) { + const id = workers[0].workerId; + return id.length > 8 ? id.slice(0, 8) + '...' : id; + } + const top = workers.slice(0, 2).map(w => { + const id = w.workerId; + return id.length > 6 ? id.slice(0, 6) : id; + }).join(', '); + const extra = workers.length > 2 ? ` +${workers.length - 2}` : ''; + return `${top}${extra}`; + }; + + const cycleSortMode = () => { + const modes: HeatmapSortMode[] = ['modifications', 'recent', 'workers', 'collisions']; + const currentIndex = modes.indexOf(sortMode); + setSortMode(modes[(currentIndex + 1) % modes.length]); + }; + + if (!visible) return null; + + return ( +
+
+

+ {'\ud83d\udd25'} + File Heatmap + {showCollisionsOnly && COLLISIONS} +

+ +
+ + {stats && ( +
+
+ + Files: {stats.totalFiles} + + + Mods: {stats.totalModifications} + + + Active: {stats.activeFiles} + + + {'\u26a0'} {stats.collisionFiles} + +
+
+ {'\u25cb'}{stats.heatDistribution.cold} + {'\u25d0'}{stats.heatDistribution.warm} + {'\u25cf'}{stats.heatDistribution.hot} + {'\ud83d\udd25'}{stats.heatDistribution.critical} +
+
+ )} + +
+ + + setFilter(e.target.value)} + /> + +
+ +
+ {loading ? ( +
Loading heatmap data...
+ ) : error ? ( +
{error}
+ ) : entries.length === 0 ? ( +
+ No file modifications detected + {showCollisionsOnly && ( +

Press the Collisions button to show all files

+ )} +
+ ) : ( +
+ {entries.map((entry, index) => ( +
setSelectedEntry(selectedEntry === entry ? null : entry)} + > + + {getHeatIcon(entry.heatLevel)} + +
+
+
+ {entry.modifications.toString().padStart(3, ' ')} + + {formatPath(entry.path)} + + {formatWorkers(entry.workers)} + 1 ? 'warning' : ''}`}> + {entry.hasCollision ? '\u26a0' : entry.activeWorkers > 1 ? '\u26a1' : ' '} + +
+ ))} +
+ )} +
+ + {selectedEntry && ( +
+
+

{formatPath(selectedEntry.path, 60)}

+ +
+
+
+ Modifications: + {selectedEntry.modifications} +
+
+ Heat Level: + + {selectedEntry.heatLevel.toUpperCase()} + +
+
+ First Modified: + {formatTime(selectedEntry.firstModified)} +
+
+ Last Modified: + {formatTime(selectedEntry.lastModified)} +
+
+ Active Workers: + {selectedEntry.activeWorkers} +
+
+ Collision: + + {selectedEntry.hasCollision ? 'Yes' : 'No'} + +
+ {selectedEntry.workers.length > 0 && ( +
+

Workers ({selectedEntry.workers.length})

+ {selectedEntry.workers.map((w, i) => ( +
+ {w.workerId} + {w.modifications} mods ({w.percentage}%) +
+ ))} +
+ )} +
+
+ )} + +
+ [s] Sort | [c] Collisions only | Click entry for details +
+
+ ); +}; + +export default FileHeatmap; diff --git a/src/web/frontend/test/FileHeatmap.test.tsx b/src/web/frontend/test/FileHeatmap.test.tsx new file mode 100644 index 0000000..5adeb85 --- /dev/null +++ b/src/web/frontend/test/FileHeatmap.test.tsx @@ -0,0 +1,297 @@ +/** + * @jest-environment jsdom + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import FileHeatmap from '../src/components/FileHeatmap'; +import { FileHeatmapEntry, FileHeatmapStats } from '../src/types'; + +// Helper to create mock Response objects +const createMockResponse = (data: T): { ok: boolean; json: () => Promise } => ({ + ok: true, + json: () => Promise.resolve(data), +}); + +// Mock fetch for API calls +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe('FileHeatmap Component', () => { + const mockStats: FileHeatmapStats = { + totalFiles: 10, + totalModifications: 50, + collisionFiles: 2, + activeFiles: 5, + heatDistribution: { cold: 4, warm: 3, hot: 2, critical: 1 }, + mostActiveDirectory: '/src/components', + avgModificationsPerFile: 5, + }; + + const mockEntries: FileHeatmapEntry[] = [ + { + path: '/src/components/Button.tsx', + modifications: 15, + heatLevel: 'critical', + workers: [ + { workerId: 'w-alpha', modifications: 10, lastModified: Date.now(), percentage: 67 }, + { workerId: 'w-beta', modifications: 5, lastModified: Date.now(), percentage: 33 }, + ], + firstModified: Date.now() - 100000, + lastModified: Date.now(), + hasCollision: true, + activeWorkers: 2, + avgModificationInterval: 5000, + }, + { + path: '/src/utils/helpers.ts', + modifications: 8, + heatLevel: 'hot', + workers: [ + { workerId: 'w-alpha', modifications: 8, lastModified: Date.now(), percentage: 100 }, + ], + firstModified: Date.now() - 50000, + lastModified: Date.now(), + hasCollision: false, + activeWorkers: 1, + avgModificationInterval: 3000, + }, + { + path: '/src/types.ts', + modifications: 3, + heatLevel: 'warm', + workers: [ + { workerId: 'w-gamma', modifications: 3, lastModified: Date.now(), percentage: 100 }, + ], + firstModified: Date.now() - 30000, + lastModified: Date.now(), + hasCollision: false, + activeWorkers: 1, + avgModificationInterval: 10000, + }, + ]; + + beforeEach(() => { + mockFetch.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + describe('Rendering', () => { + it('should render heatmap panel when visible', async () => { + mockFetch + .mockResolvedValueOnce(createMockResponse(mockEntries)) + .mockResolvedValueOnce(createMockResponse(mockStats)); + + render( {}} />); + + await waitFor(() => { + expect(screen.getByText('File Heatmap')).toBeInTheDocument(); + }); + }); + + it('should not render when not visible', () => { + render( {}} />); + + expect(screen.queryByText('File Heatmap')).not.toBeInTheDocument(); + }); + + it('should render stats section', async () => { + mockFetch + .mockResolvedValueOnce(createMockResponse(mockEntries)) + .mockResolvedValueOnce(createMockResponse(mockStats)); + + render( {}} />); + + await waitFor(() => { + // Check for stats section + expect(document.querySelector('.file-heatmap-stats')).toBeTruthy(); + }); + }); + + it('should render file entries', async () => { + mockFetch + .mockResolvedValueOnce(createMockResponse(mockEntries)) + .mockResolvedValueOnce(createMockResponse(mockStats)); + + render( {}} />); + + await waitFor(() => { + expect(screen.getByText('/src/components/Button.tsx')).toBeInTheDocument(); + expect(screen.getByText('/src/utils/helpers.ts')).toBeInTheDocument(); + expect(screen.getByText('/src/types.ts')).toBeInTheDocument(); + }); + }); + + it('should show collision class on collision entries', async () => { + mockFetch + .mockResolvedValueOnce(createMockResponse(mockEntries)) + .mockResolvedValueOnce(createMockResponse(mockStats)); + + render( {}} />); + + await waitFor(() => { + // Look for collision entry class + const entries = document.querySelectorAll('.heatmap-entry.collision'); + expect(entries.length).toBeGreaterThan(0); + }); + }); + }); + + describe('Interactions', () => { + it('should call onClose when close button clicked', async () => { + mockFetch + .mockResolvedValueOnce(createMockResponse(mockEntries)) + .mockResolvedValueOnce(createMockResponse(mockStats)); + + const onClose = vi.fn(); + render(); + + await waitFor(() => { + expect(screen.getByText('File Heatmap')).toBeInTheDocument(); + }); + + // Close button has × symbol + const closeButton = document.querySelector('.file-heatmap-close'); + expect(closeButton).toBeTruthy(); + fireEvent.click(closeButton!); + + expect(onClose).toHaveBeenCalled(); + }); + + it('should have collision toggle button', async () => { + mockFetch + .mockResolvedValueOnce(createMockResponse(mockEntries)) + .mockResolvedValueOnce(createMockResponse(mockStats)); + + render( {}} />); + + await waitFor(() => { + expect(screen.getByText('File Heatmap')).toBeInTheDocument(); + }); + + const collisionToggle = screen.getByRole('button', { name: /collisions/i }); + expect(collisionToggle).toBeTruthy(); + }); + + it('should have sort button', async () => { + mockFetch + .mockResolvedValueOnce(createMockResponse(mockEntries)) + .mockResolvedValueOnce(createMockResponse(mockStats)); + + render( {}} />); + + await waitFor(() => { + expect(screen.getByText('File Heatmap')).toBeInTheDocument(); + }); + + const sortButton = screen.getByRole('button', { name: /sort.*modifications/i }); + expect(sortButton).toBeTruthy(); + }); + + it('should have filter input', async () => { + mockFetch + .mockResolvedValueOnce(createMockResponse(mockEntries)) + .mockResolvedValueOnce(createMockResponse(mockStats)); + + render( {}} />); + + await waitFor(() => { + expect(screen.getByText('File Heatmap')).toBeInTheDocument(); + }); + + const filterInput = screen.getByPlaceholderText(/filter|directory/i); + expect(filterInput).toBeTruthy(); + }); + + it('should select entry for detail view', async () => { + mockFetch + .mockResolvedValueOnce(createMockResponse(mockEntries)) + .mockResolvedValueOnce(createMockResponse(mockStats)); + + render( {}} />); + + // Wait for entries to render + await waitFor(() => { + expect(screen.getByText('/src/components/Button.tsx')).toBeInTheDocument(); + }); + + // Click on an entry + const entry = screen.getByText('/src/components/Button.tsx'); + fireEvent.click(entry); + + // Should show detail panel + await waitFor(() => { + const detailPanel = document.querySelector('.file-heatmap-detail'); + expect(detailPanel).toBeTruthy(); + }); + }); + }); + + describe('Error handling', () => { + it('should show error message when fetch fails', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + render( {}} />); + + await waitFor(() => { + expect(screen.getByText(/error/i)).toBeInTheDocument(); + }); + }); + + it('should show loading state', () => { + mockFetch.mockImplementationOnce(() => new Promise(() => {})); // Never resolves + + render( {}} />); + + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); + + it('should show empty state when no entries', async () => { + mockFetch + .mockResolvedValueOnce(createMockResponse([])) + .mockResolvedValueOnce(createMockResponse(mockStats)); + + render( {}} />); + + // Wait for loading to complete and empty state to show + await waitFor(() => { + const emptyState = document.querySelector('.heatmap-empty'); + expect(emptyState).toBeTruthy(); + }); + }); + }); + + describe('Heat levels', () => { + it('should render heat bar fills', async () => { + mockFetch + .mockResolvedValueOnce(createMockResponse(mockEntries)) + .mockResolvedValueOnce(createMockResponse(mockStats)); + + render( {}} />); + + await waitFor(() => { + // Check for heat bar fills + const heatBars = document.querySelectorAll('.heat-bar-fill'); + expect(heatBars.length).toBeGreaterThan(0); + }); + }); + + it('should show heat distribution in stats', async () => { + mockFetch + .mockResolvedValueOnce(createMockResponse(mockEntries)) + .mockResolvedValueOnce(createMockResponse(mockStats)); + + render( {}} />); + + await waitFor(() => { + // Check heat distribution section exists + const distribution = document.querySelector('.heat-distribution'); + expect(distribution).toBeTruthy(); + }); + }); + }); +});