- 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.
636 lines
18 KiB
JavaScript
636 lines
18 KiB
JavaScript
/**
|
|
* 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 };
|