feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,181 @@
/**
* Performance Profiler Module
*
* Provides performance profiling with flamegraph and timeline visualization
* for LiveComponents and general application performance analysis.
*
* @module performance-profiler
*/
import { PerformanceProfiler, LiveComponentsProfiler } from './profiler.js';
import { FlamegraphVisualizer, TimelineVisualizer } from './visualizer.js';
export const definition = {
name: 'performance-profiler',
version: '1.0.0',
dependencies: [],
provides: ['performance-profiling', 'flamegraph', 'timeline'],
priority: 0
};
let globalProfiler = null;
let flamegraphViz = null;
let timelineViz = null;
/**
* Initialize performance profiler module
* @param {Object} config - Module configuration
* @param {Object} state - Module state manager
*/
export async function init(config = {}, state) {
console.log('[PerformanceProfiler] Initializing performance profiler module');
const enabled = config.enabled ?? false;
// Create global profiler instance
globalProfiler = new PerformanceProfiler({
enabled,
maxSamples: config.maxSamples ?? 1000,
samplingInterval: config.samplingInterval ?? 10,
autoStart: config.autoStart ?? false
});
// Initialize visualizers if containers exist
if (config.flamegraphContainer) {
const container = document.querySelector(config.flamegraphContainer);
if (container) {
flamegraphViz = new FlamegraphVisualizer(container, config.flamegraph ?? {});
console.log('[PerformanceProfiler] Flamegraph visualizer initialized');
}
}
if (config.timelineContainer) {
const container = document.querySelector(config.timelineContainer);
if (container) {
timelineViz = new TimelineVisualizer(container, config.timeline ?? {});
console.log('[PerformanceProfiler] Timeline visualizer initialized');
}
}
// Expose global API
if (typeof window !== 'undefined') {
window.PerformanceProfiler = {
profiler: globalProfiler,
flamegraph: flamegraphViz,
timeline: timelineViz,
// Convenience methods
start: () => globalProfiler.start(),
stop: () => {
const results = globalProfiler.stop();
if (results) {
console.log('[PerformanceProfiler] Profiling results:', results);
// Auto-render visualizations if available
if (flamegraphViz) {
const flamegraphData = globalProfiler.generateFlamegraph();
flamegraphViz.render(flamegraphData);
}
if (timelineViz) {
const timelineData = globalProfiler.generateTimeline();
timelineViz.render(timelineData);
}
}
return results;
},
mark: (name, metadata) => globalProfiler.mark(name, metadata),
measure: (name, start, end) => globalProfiler.measure(name, start, end),
exportChromeTrace: () => globalProfiler.exportToChromeTrace(),
createComponentProfiler: (component, options) => {
return new LiveComponentsProfiler(component, {
...options,
enabled: enabled || options?.enabled
});
}
};
console.log('[PerformanceProfiler] Global API available at window.PerformanceProfiler');
}
// Auto-instrument LiveComponents if available
if (typeof window !== 'undefined' && window.LiveComponents && config.autoInstrument !== false) {
instrumentLiveComponents();
}
console.log('[PerformanceProfiler] Module initialized');
}
/**
* Auto-instrument all LiveComponents
* @private
*/
function instrumentLiveComponents() {
const liveComponents = window.LiveComponents;
if (!liveComponents || !liveComponents.registry) {
console.warn('[PerformanceProfiler] LiveComponents not available for instrumentation');
return;
}
// Instrument existing components
for (const [id, component] of liveComponents.registry.entries()) {
const profiler = new LiveComponentsProfiler(component, {
enabled: true
});
// Store profiler reference
component._profiler = profiler;
console.log(`[PerformanceProfiler] Instrumented component: ${id}`);
}
// Instrument future components
const originalRegister = liveComponents.register.bind(liveComponents);
liveComponents.register = (id, component) => {
const result = originalRegister(id, component);
const profiler = new LiveComponentsProfiler(component, {
enabled: true
});
component._profiler = profiler;
console.log(`[PerformanceProfiler] Instrumented component: ${id}`);
return result;
};
console.log('[PerformanceProfiler] LiveComponents instrumentation enabled');
}
/**
* Destroy performance profiler module
*/
export function destroy() {
if (globalProfiler) {
globalProfiler.clear();
}
if (flamegraphViz) {
flamegraphViz.clear();
}
if (timelineViz) {
timelineViz.clear();
}
if (typeof window !== 'undefined') {
delete window.PerformanceProfiler;
}
console.log('[PerformanceProfiler] Module destroyed');
}
// Export classes for advanced usage
export { PerformanceProfiler, LiveComponentsProfiler, FlamegraphVisualizer, TimelineVisualizer };
export default { init, destroy, definition };

View File

@@ -0,0 +1,500 @@
/**
* LiveComponents Performance Profiler
*
* Provides detailed performance profiling with flamegraph and timeline visualization.
*
* Features:
* - Real-time performance metrics collection
* - Flamegraph generation for call stack visualization
* - Timeline visualization for event sequencing
* - Export to Chrome DevTools format
* - Integration with Performance API
*
* @module PerformanceProfiler
*/
export class PerformanceProfiler {
constructor(options = {}) {
this.enabled = options.enabled ?? false;
this.maxSamples = options.maxSamples ?? 1000;
this.samplingInterval = options.samplingInterval ?? 10; // ms
this.autoStart = options.autoStart ?? false;
this.samples = [];
this.timeline = [];
this.marks = new Map();
this.measures = new Map();
this.isRecording = false;
this.recordingStartTime = null;
if (this.autoStart && this.enabled) {
this.start();
}
}
/**
* Start performance profiling
*/
start() {
if (this.isRecording) {
console.warn('[PerformanceProfiler] Already recording');
return;
}
this.isRecording = true;
this.recordingStartTime = performance.now();
this.samples = [];
this.timeline = [];
console.log('[PerformanceProfiler] Started recording');
}
/**
* Stop performance profiling
* @returns {Object} Profiling results
*/
stop() {
if (!this.isRecording) {
console.warn('[PerformanceProfiler] Not recording');
return null;
}
this.isRecording = false;
const duration = performance.now() - this.recordingStartTime;
const results = {
duration,
samples: this.samples,
timeline: this.timeline,
marks: Array.from(this.marks.entries()),
measures: Array.from(this.measures.entries()),
summary: this.generateSummary()
};
console.log('[PerformanceProfiler] Stopped recording', results);
return results;
}
/**
* Mark a specific point in time
* @param {string} name - Mark name
* @param {Object} metadata - Additional metadata
*/
mark(name, metadata = {}) {
if (!this.isRecording && !this.enabled) return;
const timestamp = performance.now();
const mark = {
name,
timestamp,
relativeTime: timestamp - this.recordingStartTime,
metadata
};
this.marks.set(name, mark);
// Also use native Performance API
if (performance.mark) {
performance.mark(name);
}
// Add to timeline
this.timeline.push({
type: 'mark',
...mark
});
}
/**
* Measure duration between two marks
* @param {string} name - Measure name
* @param {string} startMark - Start mark name
* @param {string} endMark - End mark name (optional, defaults to now)
*/
measure(name, startMark, endMark = null) {
if (!this.isRecording && !this.enabled) return;
const start = this.marks.get(startMark);
if (!start) {
console.warn(`[PerformanceProfiler] Start mark "${startMark}" not found`);
return;
}
const endTime = endMark
? this.marks.get(endMark)?.timestamp
: performance.now();
if (!endTime) {
console.warn(`[PerformanceProfiler] End mark "${endMark}" not found`);
return;
}
const duration = endTime - start.timestamp;
const measure = {
name,
startMark,
endMark,
duration,
startTime: start.timestamp,
endTime,
relativeStartTime: start.relativeTime,
relativeEndTime: endTime - this.recordingStartTime
};
this.measures.set(name, measure);
// Native Performance API
if (performance.measure && endMark) {
try {
performance.measure(name, startMark, endMark);
} catch (e) {
// Ignore if marks don't exist in native API
}
}
// Add to timeline
this.timeline.push({
type: 'measure',
...measure
});
}
/**
* Record a sample for flamegraph
* @param {string} functionName - Function name
* @param {Array<string>} stackTrace - Call stack
* @param {number} duration - Execution duration
*/
sample(functionName, stackTrace = [], duration = 0) {
if (!this.isRecording) return;
if (this.samples.length >= this.maxSamples) {
console.warn('[PerformanceProfiler] Max samples reached');
return;
}
this.samples.push({
timestamp: performance.now(),
relativeTime: performance.now() - this.recordingStartTime,
functionName,
stackTrace,
duration
});
}
/**
* Generate flamegraph data
* @returns {Object} Flamegraph data structure
*/
generateFlamegraph() {
if (this.samples.length === 0) {
return null;
}
// Build call tree from samples
const root = {
name: '(root)',
value: 0,
children: []
};
for (const sample of this.samples) {
let currentNode = root;
const stack = [sample.functionName, ...sample.stackTrace].reverse();
for (const funcName of stack) {
let child = currentNode.children.find(c => c.name === funcName);
if (!child) {
child = {
name: funcName,
value: 0,
children: []
};
currentNode.children.push(child);
}
child.value += sample.duration || 1;
currentNode = child;
}
}
return root;
}
/**
* Generate timeline data for visualization
* @returns {Array} Timeline events
*/
generateTimeline() {
return this.timeline.map(event => ({
...event,
category: this.categorizeEvent(event),
color: this.getEventColor(event)
}));
}
/**
* Generate summary statistics
* @returns {Object} Summary data
*/
generateSummary() {
const durations = Array.from(this.measures.values()).map(m => m.duration);
if (durations.length === 0) {
return {
totalMeasures: 0,
totalSamples: this.samples.length,
totalDuration: 0
};
}
const totalDuration = durations.reduce((sum, d) => sum + d, 0);
const avgDuration = totalDuration / durations.length;
const maxDuration = Math.max(...durations);
const minDuration = Math.min(...durations);
// Calculate percentiles
const sortedDurations = [...durations].sort((a, b) => a - b);
const p50 = sortedDurations[Math.floor(sortedDurations.length * 0.5)];
const p90 = sortedDurations[Math.floor(sortedDurations.length * 0.9)];
const p99 = sortedDurations[Math.floor(sortedDurations.length * 0.99)];
return {
totalMeasures: this.measures.size,
totalSamples: this.samples.length,
totalDuration,
avgDuration,
maxDuration,
minDuration,
percentiles: { p50, p90, p99 }
};
}
/**
* Export to Chrome DevTools Performance format
* @returns {Object} Chrome DevTools trace event format
*/
exportToChromeTrace() {
const events = [];
// Add marks
for (const [name, mark] of this.marks.entries()) {
events.push({
name,
cat: 'mark',
ph: 'R', // Instant event
ts: mark.timestamp * 1000, // microseconds
pid: 1,
tid: 1,
args: mark.metadata
});
}
// Add measures as duration events
for (const [name, measure] of this.measures.entries()) {
events.push({
name,
cat: 'measure',
ph: 'B', // Begin
ts: measure.startTime * 1000,
pid: 1,
tid: 1
});
events.push({
name,
cat: 'measure',
ph: 'E', // End
ts: measure.endTime * 1000,
pid: 1,
tid: 1
});
}
// Add samples
for (const sample of this.samples) {
events.push({
name: sample.functionName,
cat: 'sample',
ph: 'X', // Complete event
ts: sample.timestamp * 1000,
dur: (sample.duration || 1) * 1000,
pid: 1,
tid: 1,
args: {
stackTrace: sample.stackTrace
}
});
}
return {
traceEvents: events.sort((a, b) => a.ts - b.ts),
displayTimeUnit: 'ms',
metadata: {
'clock-domain': 'PERFORMANCE_NOW'
}
};
}
/**
* Categorize timeline event
* @private
*/
categorizeEvent(event) {
if (event.type === 'mark') {
if (event.name.includes('action')) return 'action';
if (event.name.includes('render')) return 'render';
if (event.name.includes('state')) return 'state';
return 'other';
}
if (event.type === 'measure') {
if (event.duration > 100) return 'slow';
if (event.duration > 16) return 'normal';
return 'fast';
}
return 'unknown';
}
/**
* Get color for event visualization
* @private
*/
getEventColor(event) {
const colorMap = {
action: '#4CAF50',
render: '#2196F3',
state: '#FF9800',
slow: '#F44336',
normal: '#FFC107',
fast: '#8BC34A',
other: '#9E9E9E',
unknown: '#607D8B'
};
return colorMap[this.categorizeEvent(event)] || '#607D8B';
}
/**
* Clear all profiling data
*/
clear() {
this.samples = [];
this.timeline = [];
this.marks.clear();
this.measures.clear();
this.recordingStartTime = null;
// Clear native Performance API
if (performance.clearMarks) {
performance.clearMarks();
}
if (performance.clearMeasures) {
performance.clearMeasures();
}
}
/**
* Get current profiling status
* @returns {Object} Status information
*/
getStatus() {
return {
enabled: this.enabled,
isRecording: this.isRecording,
samplesCount: this.samples.length,
marksCount: this.marks.size,
measuresCount: this.measures.size,
timelineEventsCount: this.timeline.length,
recordingDuration: this.isRecording
? performance.now() - this.recordingStartTime
: 0
};
}
}
/**
* LiveComponents Performance Integration
*
* Automatic profiling for LiveComponents actions and lifecycle events
*/
export class LiveComponentsProfiler extends PerformanceProfiler {
constructor(component, options = {}) {
super(options);
this.component = component;
this.actionCallDepth = 0;
if (this.enabled) {
this.instrumentComponent();
}
}
/**
* Instrument component for automatic profiling
* @private
*/
instrumentComponent() {
// Hook into action calls
const originalCall = this.component.call.bind(this.component);
this.component.call = (actionName, params, options) => {
this.actionCallDepth++;
const markName = `action:${actionName}:${this.actionCallDepth}`;
this.mark(`${markName}:start`, {
actionName,
params,
depth: this.actionCallDepth
});
const result = originalCall(actionName, params, options);
// Handle promise results
if (result instanceof Promise) {
return result.finally(() => {
this.mark(`${markName}:end`);
this.measure(`action:${actionName}`, `${markName}:start`, `${markName}:end`);
this.actionCallDepth--;
});
}
this.mark(`${markName}:end`);
this.measure(`action:${actionName}`, `${markName}:start`, `${markName}:end`);
this.actionCallDepth--;
return result;
};
// Hook into state updates
if (this.component.state) {
const originalSet = this.component.state.set.bind(this.component.state);
this.component.state.set = (key, value) => {
this.mark(`state:set:${key}`, { key, value });
return originalSet(key, value);
};
}
// Hook into renders
if (this.component.render) {
const originalRender = this.component.render.bind(this.component);
this.component.render = () => {
this.mark('render:start');
const result = originalRender();
if (result instanceof Promise) {
return result.finally(() => {
this.mark('render:end');
this.measure('render', 'render:start', 'render:end');
});
}
this.mark('render:end');
this.measure('render', 'render:start', 'render:end');
return result;
};
}
}
}
// Export default instance
export default PerformanceProfiler;

View File

@@ -0,0 +1,635 @@
/**
* Performance Visualization Components
*
* Provides flamegraph and timeline visualization for performance profiling data.
*
* @module PerformanceVisualizer
*/
/**
* Flamegraph Visualizer
*
* Renders interactive flamegraph from profiling samples
*/
export class FlamegraphVisualizer {
constructor(container, options = {}) {
this.container = typeof container === 'string'
? document.querySelector(container)
: container;
if (!this.container) {
throw new Error('Container element not found');
}
this.options = {
width: options.width ?? this.container.clientWidth,
height: options.height ?? 400,
barHeight: options.barHeight ?? 20,
barPadding: options.barPadding ?? 2,
colorScheme: options.colorScheme ?? 'category', // 'category', 'duration', 'monochrome'
minWidth: options.minWidth ?? 0.5, // Minimum width in pixels to render
...options
};
this.data = null;
this.svg = null;
this.tooltip = null;
this.selectedNode = null;
this.init();
}
/**
* Initialize SVG canvas
* @private
*/
init() {
// Create SVG element
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.svg.setAttribute('width', this.options.width);
this.svg.setAttribute('height', this.options.height);
this.svg.style.fontFamily = 'monospace';
this.svg.style.fontSize = '12px';
// Create tooltip
this.tooltip = document.createElement('div');
this.tooltip.style.position = 'absolute';
this.tooltip.style.padding = '8px';
this.tooltip.style.background = 'rgba(0, 0, 0, 0.8)';
this.tooltip.style.color = 'white';
this.tooltip.style.borderRadius = '4px';
this.tooltip.style.pointerEvents = 'none';
this.tooltip.style.display = 'none';
this.tooltip.style.zIndex = '1000';
this.tooltip.style.fontSize = '12px';
this.container.style.position = 'relative';
this.container.appendChild(this.svg);
this.container.appendChild(this.tooltip);
}
/**
* Render flamegraph from data
* @param {Object} data - Flamegraph data (hierarchical structure)
*/
render(data) {
this.data = data;
this.clear();
if (!data) {
return;
}
// Calculate total value for width scaling
const totalValue = this.calculateTotalValue(data);
// Render nodes recursively
this.renderNode(data, 0, 0, this.options.width, totalValue);
}
/**
* Render a single flamegraph node
* @private
*/
renderNode(node, x, y, width, totalValue) {
const { barHeight, barPadding, minWidth } = this.options;
// Skip if too small to render
if (width < minWidth) {
return;
}
// Create rectangle
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('x', x);
rect.setAttribute('y', y);
rect.setAttribute('width', width);
rect.setAttribute('height', barHeight - barPadding);
rect.setAttribute('fill', this.getColor(node));
rect.setAttribute('stroke', '#fff');
rect.setAttribute('stroke-width', '0.5');
rect.style.cursor = 'pointer';
// Add interactivity
rect.addEventListener('mouseenter', (e) => this.showTooltip(e, node));
rect.addEventListener('mouseleave', () => this.hideTooltip());
rect.addEventListener('click', () => this.selectNode(node));
this.svg.appendChild(rect);
// Create text label
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', x + 4);
text.setAttribute('y', y + barHeight / 2 + 4);
text.setAttribute('fill', this.getTextColor(node));
text.setAttribute('pointer-events', 'none');
text.textContent = this.truncateText(node.name, width);
this.svg.appendChild(text);
// Render children
if (node.children && node.children.length > 0) {
let childX = x;
const childY = y + barHeight;
for (const child of node.children) {
const childWidth = (child.value / node.value) * width;
this.renderNode(child, childX, childY, childWidth, totalValue);
childX += childWidth;
}
}
}
/**
* Calculate total value in tree
* @private
*/
calculateTotalValue(node) {
if (!node.children || node.children.length === 0) {
return node.value;
}
return node.children.reduce((sum, child) => {
return sum + this.calculateTotalValue(child);
}, 0);
}
/**
* Get color for node based on color scheme
* @private
*/
getColor(node) {
if (this.options.colorScheme === 'monochrome') {
return '#3b82f6';
}
if (this.options.colorScheme === 'duration') {
// Color based on node value (duration)
const intensity = Math.min(node.value / 100, 1);
const r = Math.floor(255 * intensity);
const g = Math.floor(255 * (1 - intensity));
return `rgb(${r}, ${g}, 100)`;
}
// Category-based coloring (default)
const colorMap = {
'action': '#4CAF50',
'render': '#2196F3',
'state': '#FF9800',
'network': '#9C27B0',
'dom': '#F44336',
'compute': '#00BCD4'
};
// Determine category from function name
for (const [keyword, color] of Object.entries(colorMap)) {
if (node.name.toLowerCase().includes(keyword)) {
return color;
}
}
// Hash-based color for consistency
return this.hashColor(node.name);
}
/**
* Get text color for contrast
* @private
*/
getTextColor(node) {
return '#fff'; // White text for all colors (good contrast)
}
/**
* Hash function for consistent colors
* @private
*/
hashColor(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = hash % 360;
return `hsl(${hue}, 70%, 50%)`;
}
/**
* Truncate text to fit width
* @private
*/
truncateText(text, width) {
const charWidth = 7; // Approximate character width
const maxChars = Math.floor(width / charWidth) - 2;
if (text.length <= maxChars) {
return text;
}
return text.substring(0, maxChars) + '…';
}
/**
* Show tooltip with node information
* @private
*/
showTooltip(event, node) {
const percentage = this.data
? ((node.value / this.calculateTotalValue(this.data)) * 100).toFixed(2)
: '0';
this.tooltip.innerHTML = `
<strong>${node.name}</strong><br>
Time: ${node.value.toFixed(2)}ms<br>
${percentage}% of total<br>
${node.children ? `${node.children.length} child(ren)` : 'Leaf node'}
`;
this.tooltip.style.display = 'block';
this.tooltip.style.left = `${event.pageX + 10}px`;
this.tooltip.style.top = `${event.pageY + 10}px`;
}
/**
* Hide tooltip
* @private
*/
hideTooltip() {
this.tooltip.style.display = 'none';
}
/**
* Select node (zoom/highlight)
* @private
*/
selectNode(node) {
this.selectedNode = node;
console.log('[Flamegraph] Selected node:', node);
// Could implement zoom functionality here
// this.render(node);
}
/**
* Clear flamegraph
*/
clear() {
while (this.svg.firstChild) {
this.svg.removeChild(this.svg.firstChild);
}
}
/**
* Export flamegraph as SVG
* @returns {string} SVG string
*/
exportSVG() {
return this.svg.outerHTML;
}
/**
* Export flamegraph as PNG
* @returns {Promise<Blob>} PNG image blob
*/
async exportPNG() {
const svgData = new XMLSerializer().serializeToString(this.svg);
const canvas = document.createElement('canvas');
canvas.width = this.options.width;
canvas.height = this.options.height;
const ctx = canvas.getContext('2d');
const img = new Image();
return new Promise((resolve, reject) => {
img.onload = () => {
ctx.drawImage(img, 0, 0);
canvas.toBlob(resolve, 'image/png');
};
img.onerror = reject;
img.src = 'data:image/svg+xml;base64,' + btoa(svgData);
});
}
}
/**
* Timeline Visualizer
*
* Renders interactive timeline from profiling events
*/
export class TimelineVisualizer {
constructor(container, options = {}) {
this.container = typeof container === 'string'
? document.querySelector(container)
: container;
if (!this.container) {
throw new Error('Container element not found');
}
this.options = {
width: options.width ?? this.container.clientWidth,
height: options.height ?? 200,
trackHeight: options.trackHeight ?? 30,
padding: options.padding ?? { top: 20, right: 20, bottom: 30, left: 60 },
...options
};
this.events = [];
this.canvas = null;
this.ctx = null;
this.tooltip = null;
this.scale = { x: 1, y: 1 };
this.offset = { x: 0, y: 0 };
this.init();
}
/**
* Initialize canvas
* @private
*/
init() {
// Create canvas
this.canvas = document.createElement('canvas');
this.canvas.width = this.options.width;
this.canvas.height = this.options.height;
this.canvas.style.cursor = 'crosshair';
this.ctx = this.canvas.getContext('2d');
// Create tooltip
this.tooltip = document.createElement('div');
this.tooltip.style.position = 'absolute';
this.tooltip.style.padding = '8px';
this.tooltip.style.background = 'rgba(0, 0, 0, 0.8)';
this.tooltip.style.color = 'white';
this.tooltip.style.borderRadius = '4px';
this.tooltip.style.pointerEvents = 'none';
this.tooltip.style.display = 'none';
this.tooltip.style.zIndex = '1000';
this.tooltip.style.fontSize = '12px';
this.container.style.position = 'relative';
this.container.appendChild(this.canvas);
this.container.appendChild(this.tooltip);
// Add mouse events
this.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
this.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this));
}
/**
* Render timeline from events
* @param {Array} events - Timeline events
*/
render(events) {
this.events = events;
this.clear();
if (!events || events.length === 0) {
return;
}
// Calculate time range
const times = events.map(e => e.timestamp || e.relativeTime || 0);
const minTime = Math.min(...times);
const maxTime = Math.max(...times);
const duration = maxTime - minTime;
// Calculate scale
const { padding } = this.options;
const plotWidth = this.options.width - padding.left - padding.right;
const plotHeight = this.options.height - padding.top - padding.bottom;
this.scale.x = plotWidth / duration;
// Draw axes
this.drawAxes(minTime, maxTime, duration);
// Draw events
const tracks = this.organizeIntoTracks(events);
this.scale.y = plotHeight / tracks.length;
tracks.forEach((track, index) => {
track.forEach(event => {
this.drawEvent(event, index, minTime);
});
});
}
/**
* Organize events into non-overlapping tracks
* @private
*/
organizeIntoTracks(events) {
const tracks = [];
const sortedEvents = [...events].sort((a, b) => {
const aTime = a.timestamp || a.relativeTime || 0;
const bTime = b.timestamp || b.relativeTime || 0;
return aTime - bTime;
});
for (const event of sortedEvents) {
const eventStart = event.timestamp || event.relativeTime || 0;
const eventEnd = event.type === 'measure'
? eventStart + event.duration
: eventStart + 0.1;
// Find track where event fits
let placed = false;
for (const track of tracks) {
const conflicts = track.some(e => {
const eStart = e.timestamp || e.relativeTime || 0;
const eEnd = e.type === 'measure'
? eStart + e.duration
: eStart + 0.1;
return eventStart < eEnd && eventEnd > eStart;
});
if (!conflicts) {
track.push(event);
placed = true;
break;
}
}
if (!placed) {
tracks.push([event]);
}
}
return tracks;
}
/**
* Draw axes and grid
* @private
*/
drawAxes(minTime, maxTime, duration) {
const { padding } = this.options;
const { ctx } = this;
ctx.strokeStyle = '#ccc';
ctx.lineWidth = 1;
// Y-axis
ctx.beginPath();
ctx.moveTo(padding.left, padding.top);
ctx.lineTo(padding.left, this.options.height - padding.bottom);
ctx.stroke();
// X-axis
ctx.beginPath();
ctx.moveTo(padding.left, this.options.height - padding.bottom);
ctx.lineTo(this.options.width - padding.right, this.options.height - padding.bottom);
ctx.stroke();
// Time labels
ctx.fillStyle = '#666';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
const numLabels = 5;
for (let i = 0; i <= numLabels; i++) {
const time = minTime + (duration / numLabels) * i;
const x = padding.left + (time - minTime) * this.scale.x;
const y = this.options.height - padding.bottom + 15;
ctx.fillText(`${time.toFixed(1)}ms`, x, y);
}
}
/**
* Draw a single event
* @private
*/
drawEvent(event, trackIndex, minTime) {
const { padding } = this.options;
const { ctx } = this;
const startTime = event.timestamp || event.relativeTime || 0;
const x = padding.left + (startTime - minTime) * this.scale.x;
const y = padding.top + trackIndex * this.scale.y;
const height = this.scale.y - 4;
if (event.type === 'mark') {
// Draw mark as vertical line
ctx.strokeStyle = event.color || '#2196F3';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x, y + height);
ctx.stroke();
// Draw marker
ctx.fillStyle = event.color || '#2196F3';
ctx.beginPath();
ctx.arc(x, y + height / 2, 4, 0, Math.PI * 2);
ctx.fill();
} else if (event.type === 'measure') {
// Draw measure as rectangle
const width = Math.max(event.duration * this.scale.x, 2);
ctx.fillStyle = event.color || '#4CAF50';
ctx.fillRect(x, y, width, height);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 0.5;
ctx.strokeRect(x, y, width, height);
// Draw label if wide enough
if (width > 30) {
ctx.fillStyle = '#fff';
ctx.font = '10px monospace';
ctx.textAlign = 'left';
ctx.fillText(event.name, x + 4, y + height / 2 + 4);
}
}
// Store event bounds for hover detection
event._bounds = {
x,
y,
width: event.type === 'measure' ? event.duration * this.scale.x : 8,
height
};
}
/**
* Handle mouse move for tooltip
* @private
*/
handleMouseMove(e) {
const rect = this.canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Find hovered event
const hoveredEvent = this.events.find(event => {
if (!event._bounds) return false;
const { x, y, width, height } = event._bounds;
return mouseX >= x && mouseX <= x + width &&
mouseY >= y && mouseY <= y + height;
});
if (hoveredEvent) {
this.showTooltip(e, hoveredEvent);
} else {
this.hideTooltip();
}
}
/**
* Handle mouse leave
* @private
*/
handleMouseLeave() {
this.hideTooltip();
}
/**
* Show tooltip with event information
* @private
*/
showTooltip(event, timelineEvent) {
const time = timelineEvent.timestamp || timelineEvent.relativeTime || 0;
this.tooltip.innerHTML = `
<strong>${timelineEvent.name}</strong><br>
Type: ${timelineEvent.type}<br>
Time: ${time.toFixed(2)}ms<br>
${timelineEvent.duration ? `Duration: ${timelineEvent.duration.toFixed(2)}ms` : ''}
`;
this.tooltip.style.display = 'block';
this.tooltip.style.left = `${event.pageX + 10}px`;
this.tooltip.style.top = `${event.pageY + 10}px`;
}
/**
* Hide tooltip
* @private
*/
hideTooltip() {
this.tooltip.style.display = 'none';
}
/**
* Clear timeline
*/
clear() {
this.ctx.clearRect(0, 0, this.options.width, this.options.height);
}
/**
* Export timeline as PNG
* @returns {Promise<Blob>} PNG image blob
*/
async exportPNG() {
return new Promise((resolve) => {
this.canvas.toBlob(resolve, 'image/png');
});
}
}
export default { FlamegraphVisualizer, TimelineVisualizer };