- Remove unused variables and imports across components - Fix BabylonJS material property errors (hasAlpha → useAlphaFromDiffuseTexture) - Resolve TypeScript interface extension issues in PhaseViewer - Add null safety checks for potentially undefined properties - Ensure proper array initialization before operations - Clean up unused function declarations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
540 lines
21 KiB
TypeScript
540 lines
21 KiB
TypeScript
import { useState, useMemo } from 'react'
|
||
import { useDatabaseTraceData } from '../hooks/useDatabaseTraceData'
|
||
import type { TraceEventPhase } from '../../types/trace'
|
||
|
||
interface ExtendedTraceEvent {
|
||
args: Record<string, unknown>
|
||
cat: string
|
||
name: string
|
||
ph: TraceEventPhase
|
||
pid: number
|
||
tid: number
|
||
ts: number
|
||
tts?: number
|
||
dur?: number
|
||
tdur?: number
|
||
}
|
||
|
||
const PHASE_DESCRIPTIONS: Record<string, string> = {
|
||
'M': 'Metadata',
|
||
'X': 'Complete Events',
|
||
'I': 'Instant Events',
|
||
'B': 'Begin Events',
|
||
'E': 'End Events',
|
||
'D': 'Deprecated',
|
||
'b': 'Nestable Start',
|
||
'e': 'Nestable End',
|
||
'n': 'Nestable Instant',
|
||
'S': 'Async Start',
|
||
'T': 'Async Instant',
|
||
'F': 'Async End',
|
||
'P': 'Sample',
|
||
'C': 'Counter',
|
||
'R': 'Mark'
|
||
}
|
||
|
||
const ITEMS_PER_PAGE = 50
|
||
|
||
// Helper functions to extract valuable fields
|
||
const getURL = (event: ExtendedTraceEvent): string | null => {
|
||
const args = event.args as any
|
||
return args?.url || args?.beginData?.url || args?.data?.frames?.[0]?.url || null
|
||
}
|
||
|
||
const getStackTrace = (event: ExtendedTraceEvent): any[] | null => {
|
||
const args = event.args as any
|
||
return args?.beginData?.stackTrace || null
|
||
}
|
||
|
||
|
||
const getScriptInfo = (event: ExtendedTraceEvent): { contextId?: number, scriptId?: number } => {
|
||
const args = event.args as any
|
||
return {
|
||
contextId: args?.data?.executionContextId,
|
||
scriptId: args?.data?.scriptId
|
||
}
|
||
}
|
||
|
||
const getLayoutInfo = (event: ExtendedTraceEvent): { dirtyObjects?: number, totalObjects?: number, elementCount?: number } => {
|
||
const args = event.args as any
|
||
return {
|
||
dirtyObjects: args?.beginData?.dirtyObjects,
|
||
totalObjects: args?.beginData?.totalObjects,
|
||
elementCount: args?.elementCount
|
||
}
|
||
}
|
||
|
||
const getSampleTraceId = (event: ExtendedTraceEvent): number | null => {
|
||
const args = event.args as any
|
||
return args?.beginData?.sampleTraceId || null
|
||
}
|
||
|
||
interface PhaseViewerProps {
|
||
traceId: string | null
|
||
}
|
||
|
||
export default function PhaseViewer({ traceId }: PhaseViewerProps) {
|
||
const { traceData, loading, error, stats } = useDatabaseTraceData(traceId)
|
||
const [selectedPhase, setSelectedPhase] = useState<string>('all')
|
||
const [currentPage, setCurrentPage] = useState(1)
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
||
|
||
const filteredEvents = useMemo(() => {
|
||
if (!traceData) return []
|
||
|
||
let events = traceData.traceEvents
|
||
|
||
// Filter by phase
|
||
if (selectedPhase !== 'all') {
|
||
events = events.filter(event => event.ph === selectedPhase)
|
||
}
|
||
|
||
// Filter by search term
|
||
if (searchTerm) {
|
||
const term = searchTerm.toLowerCase()
|
||
events = events.filter(event =>
|
||
event.name.toLowerCase().includes(term) ||
|
||
event.cat.toLowerCase().includes(term) ||
|
||
event.pid.toString().includes(term) ||
|
||
event.tid.toString().includes(term)
|
||
)
|
||
}
|
||
|
||
return events
|
||
}, [traceData, selectedPhase, searchTerm])
|
||
|
||
const paginatedEvents = useMemo(() => {
|
||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE
|
||
const endIndex = startIndex + ITEMS_PER_PAGE
|
||
return filteredEvents.slice(startIndex, endIndex)
|
||
}, [filteredEvents, currentPage])
|
||
|
||
const totalPages = Math.ceil(filteredEvents.length / ITEMS_PER_PAGE)
|
||
|
||
const formatTimestamp = (ts: number) => {
|
||
return (ts / 1000).toFixed(3) + 'ms'
|
||
}
|
||
|
||
const formatDuration = (dur?: number) => {
|
||
if (!dur) return '-'
|
||
return (dur / 1000).toFixed(3) + 'ms'
|
||
}
|
||
|
||
const formatThreadTime = (tts?: number) => {
|
||
if (!tts) return '-'
|
||
return (tts / 1000).toFixed(3) + 'ms'
|
||
}
|
||
|
||
const toggleRowExpansion = (eventKey: string) => {
|
||
const newExpanded = new Set(expandedRows)
|
||
if (newExpanded.has(eventKey)) {
|
||
newExpanded.delete(eventKey)
|
||
} else {
|
||
newExpanded.add(eventKey)
|
||
}
|
||
setExpandedRows(newExpanded)
|
||
}
|
||
|
||
const truncateText = (text: string, maxLength: number = 50) => {
|
||
if (text.length <= maxLength) return text
|
||
return text.substring(0, maxLength) + '...'
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
minHeight: '400px',
|
||
fontSize: '18px',
|
||
color: '#6c757d'
|
||
}}>
|
||
<div style={{
|
||
width: '50px',
|
||
height: '50px',
|
||
border: '4px solid #f3f3f3',
|
||
borderTop: '4px solid #007bff',
|
||
borderRadius: '50%',
|
||
animation: 'spin 1s linear infinite',
|
||
marginBottom: '20px'
|
||
}} />
|
||
<div>Loading trace events...</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||
<div style={{
|
||
background: '#f8d7da',
|
||
color: '#721c24',
|
||
padding: '15px',
|
||
borderRadius: '8px',
|
||
border: '1px solid #f5c6cb'
|
||
}}>
|
||
<h3>Error Loading Trace Data</h3>
|
||
<p>{error}</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!traceData || !stats) {
|
||
return <div style={{ padding: '20px', textAlign: 'center' }}>No trace data available</div>
|
||
}
|
||
|
||
const phaseOptions = Object.keys(stats.eventsByPhase).sort()
|
||
|
||
return (
|
||
<div style={{ padding: '20px', fontFamily: 'system-ui, sans-serif' }}>
|
||
<h2>Phase Event Viewer</h2>
|
||
|
||
{/* Controls */}
|
||
<div style={{
|
||
background: '#f8f9fa',
|
||
padding: '20px',
|
||
borderRadius: '8px',
|
||
marginBottom: '20px',
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: '15px',
|
||
alignItems: 'center'
|
||
}}>
|
||
{/* Phase Filter */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
|
||
<label style={{ fontWeight: 'bold', fontSize: '14px' }}>Filter by Phase:</label>
|
||
<select
|
||
value={selectedPhase}
|
||
onChange={(e) => {
|
||
setSelectedPhase(e.target.value)
|
||
setCurrentPage(1)
|
||
}}
|
||
style={{
|
||
padding: '8px 12px',
|
||
border: '1px solid #ced4da',
|
||
borderRadius: '4px',
|
||
fontSize: '14px',
|
||
minWidth: '200px'
|
||
}}
|
||
>
|
||
<option value="all">All Phases ({stats.totalEvents.toLocaleString()})</option>
|
||
{phaseOptions.map(phase => (
|
||
<option key={phase} value={phase}>
|
||
{phase} - {PHASE_DESCRIPTIONS[phase] || 'Unknown'} ({stats.eventsByPhase[phase].toLocaleString()})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Search */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
|
||
<label style={{ fontWeight: 'bold', fontSize: '14px' }}>Search:</label>
|
||
<input
|
||
type="text"
|
||
placeholder="Search by name, category, PID, or TID..."
|
||
value={searchTerm}
|
||
onChange={(e) => {
|
||
setSearchTerm(e.target.value)
|
||
setCurrentPage(1)
|
||
}}
|
||
style={{
|
||
padding: '8px 12px',
|
||
border: '1px solid #ced4da',
|
||
borderRadius: '4px',
|
||
fontSize: '14px',
|
||
minWidth: '300px'
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Results count */}
|
||
<div style={{
|
||
marginLeft: 'auto',
|
||
fontSize: '14px',
|
||
color: '#6c757d',
|
||
fontWeight: 'bold'
|
||
}}>
|
||
{filteredEvents.length.toLocaleString()} events found
|
||
</div>
|
||
</div>
|
||
|
||
{/* Pagination Controls */}
|
||
{totalPages > 1 && (
|
||
<div style={{
|
||
display: 'flex',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
gap: '10px',
|
||
marginBottom: '20px'
|
||
}}>
|
||
<button
|
||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||
disabled={currentPage === 1}
|
||
style={{
|
||
padding: '8px 16px',
|
||
border: '1px solid #ced4da',
|
||
borderRadius: '4px',
|
||
background: currentPage === 1 ? '#e9ecef' : '#fff',
|
||
cursor: currentPage === 1 ? 'not-allowed' : 'pointer'
|
||
}}
|
||
>
|
||
Previous
|
||
</button>
|
||
|
||
<span style={{ margin: '0 15px', fontSize: '14px' }}>
|
||
Page {currentPage} of {totalPages}
|
||
</span>
|
||
|
||
<button
|
||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||
disabled={currentPage === totalPages}
|
||
style={{
|
||
padding: '8px 16px',
|
||
border: '1px solid #ced4da',
|
||
borderRadius: '4px',
|
||
background: currentPage === totalPages ? '#e9ecef' : '#fff',
|
||
cursor: currentPage === totalPages ? 'not-allowed' : 'pointer'
|
||
}}
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Events Table */}
|
||
<div style={{
|
||
background: 'white',
|
||
border: '1px solid #dee2e6',
|
||
borderRadius: '8px',
|
||
overflow: 'hidden'
|
||
}}>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||
<thead>
|
||
<tr style={{ background: '#f8f9fa' }}>
|
||
<th style={{ padding: '8px', textAlign: 'center', borderBottom: '1px solid #dee2e6', fontSize: '12px', width: '30px' }}>Expand</th>
|
||
<th style={{ padding: '8px', textAlign: 'center', borderBottom: '1px solid #dee2e6', fontSize: '12px', width: '50px' }}>Phase</th>
|
||
<th style={{ padding: '8px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>Name</th>
|
||
<th style={{ padding: '8px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>Category</th>
|
||
<th style={{ padding: '8px', textAlign: 'right', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>Duration</th>
|
||
<th style={{ padding: '8px', textAlign: 'right', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>Thread Time</th>
|
||
<th style={{ padding: '8px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>URL</th>
|
||
<th style={{ padding: '8px', textAlign: 'right', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>PID</th>
|
||
<th style={{ padding: '8px', textAlign: 'right', borderBottom: '1px solid #dee2e6', fontSize: '12px' }}>TID</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{paginatedEvents.map((event, index) => {
|
||
const eventKey = `${event.pid}-${event.tid}-${event.ts}-${index}`
|
||
const isExpanded = expandedRows.has(eventKey)
|
||
const url = getURL(event as ExtendedTraceEvent)
|
||
const stackTrace = getStackTrace(event as ExtendedTraceEvent)
|
||
const layoutInfo = getLayoutInfo(event as ExtendedTraceEvent)
|
||
const scriptInfo = getScriptInfo(event as ExtendedTraceEvent)
|
||
const sampleTraceId = getSampleTraceId(event as ExtendedTraceEvent)
|
||
|
||
return (
|
||
<>
|
||
<tr key={eventKey} style={{
|
||
borderBottom: '1px solid #f1f3f4',
|
||
cursor: 'pointer'
|
||
}}
|
||
onClick={() => toggleRowExpansion(eventKey)}
|
||
>
|
||
<td style={{
|
||
padding: '8px',
|
||
textAlign: 'center',
|
||
fontSize: '12px',
|
||
color: '#007bff',
|
||
fontWeight: 'bold'
|
||
}}>
|
||
{isExpanded ? '−' : '+'}
|
||
</td>
|
||
<td style={{
|
||
padding: '8px',
|
||
fontSize: '12px',
|
||
fontFamily: 'monospace',
|
||
background: getPhaseColor(event.ph),
|
||
color: 'white',
|
||
fontWeight: 'bold',
|
||
textAlign: 'center'
|
||
}}>
|
||
{event.ph}
|
||
</td>
|
||
<td style={{ padding: '8px', fontSize: '12px', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||
{event.name}
|
||
</td>
|
||
<td style={{ padding: '8px', fontSize: '12px', color: '#6c757d', maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||
{event.cat}
|
||
</td>
|
||
<td style={{ padding: '8px', fontSize: '12px', textAlign: 'right', fontFamily: 'monospace' }}>
|
||
{formatDuration((event as ExtendedTraceEvent).dur)}
|
||
</td>
|
||
<td style={{ padding: '8px', fontSize: '12px', textAlign: 'right', fontFamily: 'monospace' }}>
|
||
{formatThreadTime((event as ExtendedTraceEvent).tts)}
|
||
</td>
|
||
<td style={{ padding: '8px', fontSize: '11px', color: '#007bff', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||
{url ? (
|
||
<a href={url} target="_blank" rel="noopener noreferrer" style={{ color: '#007bff', textDecoration: 'none' }}>
|
||
{truncateText(url, 40)}
|
||
</a>
|
||
) : '-'}
|
||
</td>
|
||
<td style={{ padding: '8px', fontSize: '12px', textAlign: 'right', fontFamily: 'monospace' }}>
|
||
{event.pid}
|
||
</td>
|
||
<td style={{ padding: '8px', fontSize: '12px', textAlign: 'right', fontFamily: 'monospace' }}>
|
||
{event.tid}
|
||
</td>
|
||
</tr>
|
||
|
||
{/* Expanded Row Details */}
|
||
{isExpanded && (
|
||
<tr key={`${eventKey}-expanded`}>
|
||
<td colSpan={9} style={{
|
||
padding: '15px',
|
||
background: '#f8f9fa',
|
||
border: '1px solid #e9ecef'
|
||
}}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '15px' }}>
|
||
|
||
{/* Timing Information */}
|
||
<div style={{ background: 'white', padding: '10px', borderRadius: '4px', border: '1px solid #dee2e6' }}>
|
||
<h4 style={{ margin: '0 0 8px 0', color: '#495057', fontSize: '14px' }}>Timing</h4>
|
||
<div style={{ fontSize: '12px', display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||
<div><strong>Timestamp:</strong> {formatTimestamp(event.ts)}</div>
|
||
<div><strong>Duration:</strong> {formatDuration((event as ExtendedTraceEvent).dur)}</div>
|
||
<div><strong>Thread Duration:</strong> {formatDuration((event as ExtendedTraceEvent).tdur)}</div>
|
||
<div><strong>Thread Time:</strong> {formatThreadTime((event as ExtendedTraceEvent).tts)}</div>
|
||
{sampleTraceId && <div><strong>Sample Trace ID:</strong> {sampleTraceId}</div>}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Script/Context Information */}
|
||
{(scriptInfo.contextId || scriptInfo.scriptId) && (
|
||
<div style={{ background: 'white', padding: '10px', borderRadius: '4px', border: '1px solid #dee2e6' }}>
|
||
<h4 style={{ margin: '0 0 8px 0', color: '#495057', fontSize: '14px' }}>Script Context</h4>
|
||
<div style={{ fontSize: '12px', display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||
{scriptInfo.contextId && <div><strong>Execution Context:</strong> {scriptInfo.contextId}</div>}
|
||
{scriptInfo.scriptId && <div><strong>Script ID:</strong> {scriptInfo.scriptId}</div>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Layout Information */}
|
||
{(layoutInfo.dirtyObjects || layoutInfo.totalObjects || layoutInfo.elementCount) && (
|
||
<div style={{ background: 'white', padding: '10px', borderRadius: '4px', border: '1px solid #dee2e6' }}>
|
||
<h4 style={{ margin: '0 0 8px 0', color: '#495057', fontSize: '14px' }}>Layout Metrics</h4>
|
||
<div style={{ fontSize: '12px', display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||
{layoutInfo.dirtyObjects && <div><strong>Dirty Objects:</strong> {layoutInfo.dirtyObjects}</div>}
|
||
{layoutInfo.totalObjects && <div><strong>Total Objects:</strong> {layoutInfo.totalObjects}</div>}
|
||
{layoutInfo.elementCount && <div><strong>Element Count:</strong> {layoutInfo.elementCount}</div>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Stack Trace */}
|
||
{stackTrace && stackTrace.length > 0 && (
|
||
<div style={{
|
||
background: 'white',
|
||
padding: '10px',
|
||
borderRadius: '4px',
|
||
border: '1px solid #dee2e6',
|
||
gridColumn: '1 / -1'
|
||
}}>
|
||
<h4 style={{ margin: '0 0 8px 0', color: '#495057', fontSize: '14px' }}>
|
||
Stack Trace ({stackTrace.length} frames)
|
||
</h4>
|
||
<div style={{
|
||
maxHeight: '200px',
|
||
overflowY: 'auto',
|
||
fontSize: '11px',
|
||
fontFamily: 'monospace',
|
||
background: '#f8f9fa',
|
||
padding: '8px',
|
||
borderRadius: '3px'
|
||
}}>
|
||
{stackTrace.slice(0, 10).map((frame: any, frameIndex: number) => (
|
||
<div key={frameIndex} style={{
|
||
marginBottom: '4px',
|
||
paddingBottom: '4px',
|
||
borderBottom: frameIndex < Math.min(stackTrace.length, 10) - 1 ? '1px solid #e9ecef' : 'none'
|
||
}}>
|
||
<div style={{ color: '#007bff', fontWeight: 'bold' }}>
|
||
{frame.functionName || '(anonymous)'}
|
||
</div>
|
||
<div style={{ color: '#6c757d' }}>
|
||
{frame.url ? truncateText(frame.url, 80) : 'unknown'}
|
||
{frame.lineNumber && `:${frame.lineNumber}`}
|
||
{frame.columnNumber && `:${frame.columnNumber}`}
|
||
</div>
|
||
</div>
|
||
))}
|
||
{stackTrace.length > 10 && (
|
||
<div style={{ color: '#6c757d', fontStyle: 'italic', marginTop: '8px' }}>
|
||
... and {stackTrace.length - 10} more frames
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{paginatedEvents.length === 0 && (
|
||
<div style={{
|
||
textAlign: 'center',
|
||
padding: '40px',
|
||
color: '#6c757d',
|
||
fontSize: '16px'
|
||
}}>
|
||
No events found matching the current filters
|
||
</div>
|
||
)}
|
||
|
||
<style>{`
|
||
@keyframes spin {
|
||
0% { transform: rotate(0deg); }
|
||
100% { transform: rotate(360deg); }
|
||
}
|
||
|
||
tr:hover {
|
||
background: #f8f9fa !important;
|
||
}
|
||
`}</style>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function getPhaseColor(phase: TraceEventPhase): string {
|
||
const colors: Record<string, string> = {
|
||
'M': '#6c757d', // Metadata - gray
|
||
'X': '#007bff', // Complete - blue
|
||
'I': '#28a745', // Instant - green
|
||
'B': '#ffc107', // Begin - yellow
|
||
'E': '#fd7e14', // End - orange
|
||
'D': '#6f42c1', // Deprecated - purple
|
||
'b': '#20c997', // Nestable start - teal
|
||
'e': '#e83e8c', // Nestable end - pink
|
||
'n': '#17a2b8', // Nestable instant - cyan
|
||
'S': '#dc3545', // Async start - red
|
||
'T': '#ffc107', // Async instant - warning
|
||
'F': '#fd7e14', // Async end - orange
|
||
'P': '#6f42c1', // Sample - purple
|
||
'C': '#20c997', // Counter - teal
|
||
'R': '#e83e8c' // Mark - pink
|
||
}
|
||
return colors[phase] || '#6c757d'
|
||
} |