rails_error_dashboard 0.6.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +101 -6
- data/app/controllers/rails_error_dashboard/application_controller.rb +8 -2
- data/app/controllers/rails_error_dashboard/errors_controller.rb +66 -14
- data/app/helpers/rails_error_dashboard/application_helper.rb +42 -10
- data/app/views/layouts/rails_error_dashboard.html.erb +307 -0
- data/app/views/rails_error_dashboard/errors/_ai_help_panel.html.erb +36 -0
- data/app/views/rails_error_dashboard/errors/_breadcrumbs_group.html.erb +64 -5
- data/app/views/rails_error_dashboard/errors/_error_row.html.erb +2 -2
- data/app/views/rails_error_dashboard/errors/_instance_variables.html.erb +1 -0
- data/app/views/rails_error_dashboard/errors/_llm_summary.html.erb +97 -0
- data/app/views/rails_error_dashboard/errors/_local_variables.html.erb +1 -0
- data/app/views/rails_error_dashboard/errors/_modals.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/_show_scripts.html.erb +21 -20
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +33 -19
- data/app/views/rails_error_dashboard/errors/_timeline.html.erb +1 -2
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +5 -1
- data/app/views/rails_error_dashboard/errors/correlation.html.erb +16 -4
- data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +7 -3
- data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/index.html.erb +7 -1
- data/app/views/rails_error_dashboard/errors/overview.html.erb +2 -2
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +3 -1
- data/app/views/rails_error_dashboard/errors/releases.html.erb +3 -1
- data/app/views/rails_error_dashboard/errors/settings.html.erb +0 -1
- data/app/views/rails_error_dashboard/errors/show.html.erb +12 -2
- data/app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb +5 -1
- data/app/views/rails_error_dashboard/errors/user_impact.html.erb +3 -1
- data/config/routes.rb +1 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +27 -0
- data/lib/rails_error_dashboard/configuration.rb +101 -1
- data/lib/rails_error_dashboard/engine.rb +14 -0
- data/lib/rails_error_dashboard/integrations/llm_middleware.rb +276 -0
- data/lib/rails_error_dashboard/integrations/llm_span_processor.rb +181 -0
- data/lib/rails_error_dashboard/integrations/o_tel.rb +45 -0
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +4 -1
- data/lib/rails_error_dashboard/queries/error_correlation.rb +17 -13
- data/lib/rails_error_dashboard/queries/errors_list.rb +14 -0
- data/lib/rails_error_dashboard/services/cascade_detector.rb +28 -18
- data/lib/rails_error_dashboard/services/llm_client.rb +368 -0
- data/lib/rails_error_dashboard/services/llm_cost_estimator.rb +85 -0
- data/lib/rails_error_dashboard/services/llm_summary.rb +91 -0
- data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +87 -0
- data/lib/rails_error_dashboard/subscribers/llm_call_subscriber.rb +150 -0
- data/lib/rails_error_dashboard/value_objects/llm_call_event.rb +92 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +8 -0
- metadata +13 -3
|
@@ -521,6 +521,48 @@ dd { color: var(--text-primary); }
|
|
|
521
521
|
.offcanvas-body { flex-grow: 1; padding: var(--space-4); overflow-y: auto; }
|
|
522
522
|
.offcanvas-backdrop { position: fixed; top: 0; left: 0; z-index: 1040; width: 100vw; height: 100vh; background: rgba(0,0,0,0.5); }
|
|
523
523
|
|
|
524
|
+
.red-ai-help-backdrop { position: fixed; inset: 0; z-index: 1050; background: rgba(15, 23, 42, 0.42); }
|
|
525
|
+
.red-ai-help-panel {
|
|
526
|
+
position: fixed; top: 0; right: 0; bottom: 0; z-index: 1055;
|
|
527
|
+
display: flex; flex-direction: column; width: min(460px, 100vw);
|
|
528
|
+
background: var(--surface-primary); border-left: 1px solid var(--border-primary);
|
|
529
|
+
box-shadow: var(--shadow-lg); transform: translateX(100%);
|
|
530
|
+
transition: transform var(--transition-normal); visibility: hidden;
|
|
531
|
+
}
|
|
532
|
+
.red-ai-help-panel.is-open { transform: translateX(0); visibility: visible; }
|
|
533
|
+
.red-ai-help-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); padding: var(--space-5); border-bottom: 1px solid var(--border-primary); }
|
|
534
|
+
.red-ai-help-title { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 700; color: var(--text-primary); }
|
|
535
|
+
.red-ai-help-title i { color: var(--accent); }
|
|
536
|
+
.red-ai-help-subtitle { margin-top: 3px; font-size: 12px; color: var(--text-tertiary); }
|
|
537
|
+
.red-ai-help-messages { flex: 1; display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-4); overflow-y: auto; }
|
|
538
|
+
.red-ai-help-empty { padding: var(--space-4); border: 1px dashed var(--border-primary); border-radius: var(--radius-md); color: var(--text-secondary); font-size: 13px; }
|
|
539
|
+
.red-ai-help-message { border: 1px solid var(--border-primary); border-radius: var(--radius-md); padding: var(--space-3); font-size: 13px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
540
|
+
.red-ai-help-message.user { align-self: flex-end; max-width: 88%; background: var(--accent-subtle); border-color: color-mix(in oklch, var(--accent) 35%, var(--border-primary)); color: var(--text-primary); }
|
|
541
|
+
.red-ai-help-message.assistant { background: var(--surface-base); color: var(--text-primary); white-space: normal; }
|
|
542
|
+
.red-ai-help-message.assistant > *:first-child { margin-top: 0; }
|
|
543
|
+
.red-ai-help-message.assistant > *:last-child { margin-bottom: 0; }
|
|
544
|
+
.red-ai-help-message.assistant p { margin: 0 0 var(--space-2); }
|
|
545
|
+
.red-ai-help-message.assistant h1,
|
|
546
|
+
.red-ai-help-message.assistant h2,
|
|
547
|
+
.red-ai-help-message.assistant h3 { margin: var(--space-3) 0 var(--space-2); font-size: 14px; line-height: 1.35; }
|
|
548
|
+
.red-ai-help-message.assistant ul,
|
|
549
|
+
.red-ai-help-message.assistant ol { margin: 0 0 var(--space-2) var(--space-5); padding: 0; }
|
|
550
|
+
.red-ai-help-message.assistant li { margin: 2px 0; }
|
|
551
|
+
.red-ai-help-message.assistant pre { margin: var(--space-2) 0; padding: var(--space-3); max-width: 100%; overflow-x: auto; background: var(--surface-secondary); border: 1px solid var(--border-primary); }
|
|
552
|
+
.red-ai-help-message.assistant pre code { color: var(--text-primary); white-space: pre; }
|
|
553
|
+
.red-ai-help-message.assistant code { font-size: 12px; overflow-wrap: anywhere; }
|
|
554
|
+
.red-ai-help-message.assistant blockquote { margin: var(--space-2) 0; padding-left: var(--space-3); border-left: 3px solid var(--border-primary); color: var(--text-secondary); }
|
|
555
|
+
.red-ai-help-message.assistant a { overflow-wrap: anywhere; }
|
|
556
|
+
.red-ai-help-message.error { background: var(--status-critical-bg); border-color: var(--status-critical); color: var(--status-critical); }
|
|
557
|
+
.red-ai-help-form { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-4); border-top: 1px solid var(--border-primary); background: var(--surface-primary); }
|
|
558
|
+
.red-ai-help-form textarea { width: 100%; resize: vertical; min-height: 92px; max-height: 220px; padding: var(--space-3); border: 1px solid var(--border-primary); border-radius: var(--radius-md); background: var(--surface-base); color: var(--text-primary); font: inherit; outline: none; }
|
|
559
|
+
.red-ai-help-form textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-subtle); }
|
|
560
|
+
.red-ai-help-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
|
|
561
|
+
.red-ai-help-status { color: var(--text-tertiary); font-size: 12px; min-height: 18px; }
|
|
562
|
+
@media (max-width: 575.98px) {
|
|
563
|
+
.red-ai-help-panel { width: 100vw; }
|
|
564
|
+
}
|
|
565
|
+
|
|
524
566
|
/* Nav */
|
|
525
567
|
.nav { display: flex; flex-direction: column; list-style: none; padding: 0; margin: 0; }
|
|
526
568
|
.nav-item { }
|
|
@@ -1611,6 +1653,252 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
1611
1653
|
return null;
|
|
1612
1654
|
}
|
|
1613
1655
|
|
|
1656
|
+
function setAiHelpOpen(open) {
|
|
1657
|
+
var panel = document.getElementById('red-ai-help-panel');
|
|
1658
|
+
var backdrop = document.getElementById('red-ai-help-backdrop');
|
|
1659
|
+
if (!panel || !backdrop) return;
|
|
1660
|
+
|
|
1661
|
+
panel.classList.toggle('is-open', open);
|
|
1662
|
+
panel.setAttribute('aria-hidden', open ? 'false' : 'true');
|
|
1663
|
+
backdrop.hidden = !open;
|
|
1664
|
+
|
|
1665
|
+
if (open) {
|
|
1666
|
+
var input = document.getElementById('red-ai-help-question');
|
|
1667
|
+
if (input) setTimeout(function() { input.focus(); }, 150);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
function appendAiHelpMessage(role, text) {
|
|
1672
|
+
var messages = document.getElementById('red-ai-help-messages');
|
|
1673
|
+
if (!messages) return null;
|
|
1674
|
+
|
|
1675
|
+
var empty = messages.querySelector('.red-ai-help-empty');
|
|
1676
|
+
if (empty) empty.remove();
|
|
1677
|
+
|
|
1678
|
+
var message = document.createElement('div');
|
|
1679
|
+
message.className = 'red-ai-help-message ' + role;
|
|
1680
|
+
if (role === 'assistant') {
|
|
1681
|
+
message.dataset.markdown = text || '';
|
|
1682
|
+
renderAiHelpMarkdown(message);
|
|
1683
|
+
} else {
|
|
1684
|
+
message.textContent = text;
|
|
1685
|
+
}
|
|
1686
|
+
messages.appendChild(message);
|
|
1687
|
+
messages.scrollTop = messages.scrollHeight;
|
|
1688
|
+
return message;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function escapeAiHelpHtml(text) {
|
|
1692
|
+
return String(text || '').replace(/[&<>"']/g, function(ch) {
|
|
1693
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch];
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
function renderAiHelpInlineMarkdown(text) {
|
|
1698
|
+
var escaped = escapeAiHelpHtml(text);
|
|
1699
|
+
escaped = escaped.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
1700
|
+
escaped = escaped.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
1701
|
+
escaped = escaped.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
|
|
1702
|
+
return escaped.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, function(_, label, url) {
|
|
1703
|
+
return '<a href="' + url + '" target="_blank" rel="noopener">' + label + '</a>';
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
function renderAiHelpMarkdown(message) {
|
|
1708
|
+
var markdown = message.dataset.markdown || '';
|
|
1709
|
+
var lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
|
1710
|
+
var html = [];
|
|
1711
|
+
var paragraph = [];
|
|
1712
|
+
var listType = null;
|
|
1713
|
+
var inCode = false;
|
|
1714
|
+
var codeLines = [];
|
|
1715
|
+
|
|
1716
|
+
function flushParagraph() {
|
|
1717
|
+
if (!paragraph.length) return;
|
|
1718
|
+
html.push('<p>' + renderAiHelpInlineMarkdown(paragraph.join(' ')) + '</p>');
|
|
1719
|
+
paragraph = [];
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
function flushList() {
|
|
1723
|
+
if (!listType) return;
|
|
1724
|
+
html.push('</' + listType + '>');
|
|
1725
|
+
listType = null;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
lines.forEach(function(line) {
|
|
1729
|
+
var codeFence = line.match(/^```/);
|
|
1730
|
+
if (codeFence) {
|
|
1731
|
+
if (inCode) {
|
|
1732
|
+
html.push('<pre><code>' + escapeAiHelpHtml(codeLines.join('\n')) + '</code></pre>');
|
|
1733
|
+
codeLines = [];
|
|
1734
|
+
inCode = false;
|
|
1735
|
+
} else {
|
|
1736
|
+
flushParagraph();
|
|
1737
|
+
flushList();
|
|
1738
|
+
inCode = true;
|
|
1739
|
+
}
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
if (inCode) {
|
|
1744
|
+
codeLines.push(line);
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
if (/^\s*$/.test(line)) {
|
|
1749
|
+
flushParagraph();
|
|
1750
|
+
flushList();
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
var heading = line.match(/^(#{1,3})\s+(.+)$/);
|
|
1755
|
+
if (heading) {
|
|
1756
|
+
flushParagraph();
|
|
1757
|
+
flushList();
|
|
1758
|
+
html.push('<h' + heading[1].length + '>' + renderAiHelpInlineMarkdown(heading[2]) + '</h' + heading[1].length + '>');
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
var unordered = line.match(/^\s*[-*]\s+(.+)$/);
|
|
1763
|
+
var ordered = line.match(/^\s*\d+\.\s+(.+)$/);
|
|
1764
|
+
if (unordered || ordered) {
|
|
1765
|
+
flushParagraph();
|
|
1766
|
+
var desiredList = unordered ? 'ul' : 'ol';
|
|
1767
|
+
if (listType !== desiredList) {
|
|
1768
|
+
flushList();
|
|
1769
|
+
html.push('<' + desiredList + '>');
|
|
1770
|
+
listType = desiredList;
|
|
1771
|
+
}
|
|
1772
|
+
html.push('<li>' + renderAiHelpInlineMarkdown((unordered || ordered)[1]) + '</li>');
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
var quote = line.match(/^>\s?(.+)$/);
|
|
1777
|
+
if (quote) {
|
|
1778
|
+
flushParagraph();
|
|
1779
|
+
flushList();
|
|
1780
|
+
html.push('<blockquote>' + renderAiHelpInlineMarkdown(quote[1]) + '</blockquote>');
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
paragraph.push(line.trim());
|
|
1785
|
+
});
|
|
1786
|
+
|
|
1787
|
+
if (inCode) {
|
|
1788
|
+
html.push('<pre><code>' + escapeAiHelpHtml(codeLines.join('\n')) + '</code></pre>');
|
|
1789
|
+
}
|
|
1790
|
+
flushParagraph();
|
|
1791
|
+
flushList();
|
|
1792
|
+
|
|
1793
|
+
message.innerHTML = html.join('') || '<p></p>';
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
function submitAiHelpQuestion(form) {
|
|
1797
|
+
var input = document.getElementById('red-ai-help-question');
|
|
1798
|
+
var status = document.getElementById('red-ai-help-status');
|
|
1799
|
+
var button = form.querySelector('button[type="submit"]');
|
|
1800
|
+
var question = input ? input.value.trim() : '';
|
|
1801
|
+
if (!question) {
|
|
1802
|
+
if (status) status.textContent = 'Enter a question first.';
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
appendAiHelpMessage('user', question);
|
|
1807
|
+
if (input) input.value = '';
|
|
1808
|
+
if (status) status.textContent = 'Streaming...';
|
|
1809
|
+
if (button) button.disabled = true;
|
|
1810
|
+
|
|
1811
|
+
var tokenMeta = document.querySelector('meta[name="csrf-token"]');
|
|
1812
|
+
fetch(form.dataset.aiHelpUrl, {
|
|
1813
|
+
method: 'POST',
|
|
1814
|
+
headers: {
|
|
1815
|
+
'Content-Type': 'application/json',
|
|
1816
|
+
'Accept': 'application/json',
|
|
1817
|
+
'X-CSRF-Token': tokenMeta ? tokenMeta.content : ''
|
|
1818
|
+
},
|
|
1819
|
+
body: JSON.stringify({ question: question })
|
|
1820
|
+
}).then(function(response) {
|
|
1821
|
+
if (!response.ok) {
|
|
1822
|
+
return response.json().then(function(data) {
|
|
1823
|
+
throw new Error(data.error || 'AI Help request failed.');
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
if (!response.body || !window.TextDecoder) {
|
|
1827
|
+
throw new Error('Streaming is not supported by this browser.');
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
var assistantMessage = appendAiHelpMessage('assistant', '');
|
|
1831
|
+
var reader = response.body.getReader();
|
|
1832
|
+
var decoder = new TextDecoder();
|
|
1833
|
+
var buffer = '';
|
|
1834
|
+
var streamError = null;
|
|
1835
|
+
|
|
1836
|
+
function processEvent(rawEvent) {
|
|
1837
|
+
var eventName = 'message';
|
|
1838
|
+
var dataLines = [];
|
|
1839
|
+
|
|
1840
|
+
rawEvent.split('\n').forEach(function(line) {
|
|
1841
|
+
if (!line || line.charAt(0) === ':') return;
|
|
1842
|
+
var separator = line.indexOf(':');
|
|
1843
|
+
var field = separator === -1 ? line : line.slice(0, separator);
|
|
1844
|
+
var value = separator === -1 ? '' : line.slice(separator + 1).replace(/^ /, '');
|
|
1845
|
+
if (field === 'event') eventName = value;
|
|
1846
|
+
if (field === 'data') dataLines.push(value);
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1849
|
+
if (!dataLines.length) return;
|
|
1850
|
+
var data = JSON.parse(dataLines.join('\n'));
|
|
1851
|
+
|
|
1852
|
+
if (eventName === 'chunk') {
|
|
1853
|
+
assistantMessage.dataset.markdown = (assistantMessage.dataset.markdown || '') + (data.text || '');
|
|
1854
|
+
renderAiHelpMarkdown(assistantMessage);
|
|
1855
|
+
var messages = document.getElementById('red-ai-help-messages');
|
|
1856
|
+
if (messages) messages.scrollTop = messages.scrollHeight;
|
|
1857
|
+
} else if (eventName === 'done') {
|
|
1858
|
+
if (status) status.textContent = data.provider && data.model ? data.provider + ' - ' + data.model : '';
|
|
1859
|
+
} else if (eventName === 'error') {
|
|
1860
|
+
streamError = data.error || 'AI Help request failed.';
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
function processBuffer(finalChunk) {
|
|
1865
|
+
var separator;
|
|
1866
|
+
while ((separator = buffer.indexOf('\n\n')) !== -1) {
|
|
1867
|
+
processEvent(buffer.slice(0, separator));
|
|
1868
|
+
buffer = buffer.slice(separator + 2);
|
|
1869
|
+
}
|
|
1870
|
+
if (finalChunk && buffer.trim()) {
|
|
1871
|
+
processEvent(buffer);
|
|
1872
|
+
buffer = '';
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
function readNext() {
|
|
1877
|
+
return reader.read().then(function(result) {
|
|
1878
|
+
if (result.done) {
|
|
1879
|
+
buffer += decoder.decode();
|
|
1880
|
+
processBuffer(true);
|
|
1881
|
+
if (streamError) throw new Error(streamError);
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
buffer += decoder.decode(result.value, { stream: true }).replace(/\r\n/g, '\n');
|
|
1886
|
+
processBuffer(false);
|
|
1887
|
+
if (streamError) throw new Error(streamError);
|
|
1888
|
+
return readNext();
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
return readNext();
|
|
1893
|
+
}).catch(function(error) {
|
|
1894
|
+
appendAiHelpMessage('error', error.message);
|
|
1895
|
+
if (status) status.textContent = 'Request failed.';
|
|
1896
|
+
}).finally(function() {
|
|
1897
|
+
if (button) button.disabled = false;
|
|
1898
|
+
if (input) input.focus();
|
|
1899
|
+
});
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1614
1902
|
document.addEventListener('click', function(e) {
|
|
1615
1903
|
// Row navigation: <tr data-red-row-href="/path"> — clicking anywhere on the row
|
|
1616
1904
|
// navigates, except when clicking inside a checkbox cell or interactive elements.
|
|
@@ -1656,6 +1944,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
1656
1944
|
window.downloadErrorJSON({ currentTarget: actionEl });
|
|
1657
1945
|
}
|
|
1658
1946
|
break;
|
|
1947
|
+
case 'open-ai-help':
|
|
1948
|
+
setAiHelpOpen(true);
|
|
1949
|
+
break;
|
|
1950
|
+
case 'close-ai-help':
|
|
1951
|
+
setAiHelpOpen(false);
|
|
1952
|
+
break;
|
|
1659
1953
|
case 'switch-tab':
|
|
1660
1954
|
if (typeof window.switchTab === 'function' && actionEl.dataset.redTab) {
|
|
1661
1955
|
window.switchTab(actionEl.dataset.redTab);
|
|
@@ -1700,6 +1994,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
1700
1994
|
break;
|
|
1701
1995
|
}
|
|
1702
1996
|
});
|
|
1997
|
+
|
|
1998
|
+
document.addEventListener('submit', function(e) {
|
|
1999
|
+
if (e.target && e.target.id === 'red-ai-help-form') {
|
|
2000
|
+
e.preventDefault();
|
|
2001
|
+
submitAiHelpQuestion(e.target);
|
|
2002
|
+
}
|
|
2003
|
+
});
|
|
2004
|
+
|
|
2005
|
+
document.addEventListener('keydown', function(e) {
|
|
2006
|
+
if (e.key === 'Escape') {
|
|
2007
|
+
setAiHelpOpen(false);
|
|
2008
|
+
}
|
|
2009
|
+
});
|
|
1703
2010
|
});
|
|
1704
2011
|
</script>
|
|
1705
2012
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<div id="red-ai-help-backdrop" class="red-ai-help-backdrop" data-red-action="close-ai-help" hidden></div>
|
|
2
|
+
|
|
3
|
+
<aside id="red-ai-help-panel" class="red-ai-help-panel" aria-labelledby="red-ai-help-title" aria-hidden="true">
|
|
4
|
+
<div class="red-ai-help-header">
|
|
5
|
+
<div>
|
|
6
|
+
<div id="red-ai-help-title" class="red-ai-help-title">
|
|
7
|
+
<i class="bi bi-stars"></i>
|
|
8
|
+
AI Help
|
|
9
|
+
</div>
|
|
10
|
+
<div class="red-ai-help-subtitle">
|
|
11
|
+
<%= RailsErrorDashboard.configuration.effective_llm_provider.to_s.titleize %>
|
|
12
|
+
·
|
|
13
|
+
<%= RailsErrorDashboard.configuration.effective_llm_model %>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
<button type="button" class="btn-close" data-red-action="close-ai-help" aria-label="Close AI Help"></button>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div id="red-ai-help-messages" class="red-ai-help-messages" aria-live="polite">
|
|
20
|
+
<div class="red-ai-help-empty">
|
|
21
|
+
Ask about root cause, reproduction steps, likely fixes, or what to inspect next.
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<form id="red-ai-help-form" class="red-ai-help-form" data-ai-help-url="<%= ai_help_error_path(error, **app_context) %>">
|
|
26
|
+
<label for="red-ai-help-question">Question</label>
|
|
27
|
+
<textarea id="red-ai-help-question" name="question" rows="4" maxlength="4000" placeholder="What is the likely root cause?"></textarea>
|
|
28
|
+
<div class="red-ai-help-footer">
|
|
29
|
+
<span id="red-ai-help-status" class="red-ai-help-status"></span>
|
|
30
|
+
<button type="submit" class="btn btn-primary">
|
|
31
|
+
<i class="bi bi-send"></i>
|
|
32
|
+
Ask
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
</form>
|
|
36
|
+
</aside>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<!-- Breadcrumbs (Request Activity Trail) -->
|
|
2
2
|
<% if RailsErrorDashboard.configuration.enable_breadcrumbs && error.respond_to?(:breadcrumbs) && error.breadcrumbs.present? %>
|
|
3
3
|
<% breadcrumbs = JSON.parse(error.breadcrumbs) rescue [] %>
|
|
4
|
+
<% breadcrumbs = [] unless breadcrumbs.is_a?(Array) %>
|
|
4
5
|
<% if breadcrumbs.any? %>
|
|
5
6
|
<% deprecation_crumbs = breadcrumbs.select { |c| c["c"] == "deprecation" } %>
|
|
6
7
|
<% if deprecation_crumbs.any? %>
|
|
@@ -209,13 +210,71 @@
|
|
|
209
210
|
</thead>
|
|
210
211
|
<tbody>
|
|
211
212
|
<% breadcrumbs.each_with_index do |crumb, i| %>
|
|
212
|
-
|
|
213
|
+
<% cat = crumb["c"].to_s %>
|
|
214
|
+
<% meta = crumb["meta"].is_a?(Hash) ? crumb["meta"] : {} %>
|
|
215
|
+
<% is_llm = cat == "llm" %>
|
|
216
|
+
<% is_tool = cat == "llm_tool" %>
|
|
217
|
+
<% prev_cat = i > 0 ? breadcrumbs[i - 1]["c"].to_s : nil %>
|
|
218
|
+
<% nested = is_tool && (prev_cat == "llm" || prev_cat == "llm_tool") %>
|
|
219
|
+
<tr<%= nested ? ' class="llm-tool-row"'.html_safe : "".html_safe %>>
|
|
213
220
|
<td class="text-muted"><%= i + 1 %></td>
|
|
214
|
-
<td><span class="badge bg-<%= breadcrumb_badge_color(
|
|
215
|
-
<td
|
|
221
|
+
<td><span class="badge bg-<%= breadcrumb_badge_color(cat) %>"><%= cat %></span></td>
|
|
222
|
+
<td<%= ' style="padding-left: 2rem;"'.html_safe if nested %>>
|
|
223
|
+
<% if nested %><i class="bi bi-arrow-return-right text-muted me-1" aria-hidden="true"></i><% end %>
|
|
216
224
|
<code style="word-break: break-all; font-size: 0.85em;"><%= truncate(crumb["m"].to_s, length: 200) %></code>
|
|
217
|
-
<% if
|
|
218
|
-
|
|
225
|
+
<% if is_llm %>
|
|
226
|
+
<% provider = meta["provider"].presence %>
|
|
227
|
+
<% model = meta["model"].presence %>
|
|
228
|
+
<% in_tok = meta["input_tokens"].presence %>
|
|
229
|
+
<% out_tok = meta["output_tokens"].presence %>
|
|
230
|
+
<% cost = meta["cost_usd"].presence %>
|
|
231
|
+
<% status = meta["status"].presence %>
|
|
232
|
+
<% err_class = meta["error_class"].presence %>
|
|
233
|
+
<% err_msg = meta["error_message"].presence %>
|
|
234
|
+
<br><small class="text-muted">
|
|
235
|
+
<% if provider || model %>
|
|
236
|
+
<i class="bi bi-cpu" aria-hidden="true"></i>
|
|
237
|
+
<%= [ provider, model ].compact.join(" · ") %>
|
|
238
|
+
<% end %>
|
|
239
|
+
<% if in_tok || out_tok %>
|
|
240
|
+
· <i class="bi bi-input-cursor-text" aria-hidden="true"></i>
|
|
241
|
+
in:<strong><%= in_tok || "?" %></strong>
|
|
242
|
+
/ out:<strong><%= out_tok || "?" %></strong>
|
|
243
|
+
<% end %>
|
|
244
|
+
<% if cost %>
|
|
245
|
+
· <i class="bi bi-currency-dollar" aria-hidden="true"></i>
|
|
246
|
+
<strong>$<%= "%.6f" % cost.to_f %></strong>
|
|
247
|
+
<% end %>
|
|
248
|
+
<% if status && status != "success" %>
|
|
249
|
+
· <span class="badge bg-danger"><%= status %></span>
|
|
250
|
+
<% end %>
|
|
251
|
+
</small>
|
|
252
|
+
<% if err_class || err_msg %>
|
|
253
|
+
<br><small class="text-danger">
|
|
254
|
+
<i class="bi bi-exclamation-triangle" aria-hidden="true"></i>
|
|
255
|
+
<%= [ err_class, err_msg ].compact.join(": ") %>
|
|
256
|
+
</small>
|
|
257
|
+
<% end %>
|
|
258
|
+
<% elsif is_tool %>
|
|
259
|
+
<% tool_name = meta["tool_name"].presence %>
|
|
260
|
+
<% tool_args = meta["tool_arguments"].presence %>
|
|
261
|
+
<% tool_result = meta["tool_result"].presence %>
|
|
262
|
+
<% if tool_name || tool_args || tool_result %>
|
|
263
|
+
<br><small class="text-muted">
|
|
264
|
+
<% if tool_name %>
|
|
265
|
+
<i class="bi bi-wrench-adjustable" aria-hidden="true"></i>
|
|
266
|
+
<strong><%= tool_name %></strong>
|
|
267
|
+
<% end %>
|
|
268
|
+
<% if tool_args %>
|
|
269
|
+
· args: <code style="font-size: 0.85em;"><%= truncate(tool_args, length: 120) %></code>
|
|
270
|
+
<% end %>
|
|
271
|
+
<% if tool_result %>
|
|
272
|
+
· result: <code style="font-size: 0.85em;"><%= truncate(tool_result, length: 120) %></code>
|
|
273
|
+
<% end %>
|
|
274
|
+
</small>
|
|
275
|
+
<% end %>
|
|
276
|
+
<% elsif meta.present? %>
|
|
277
|
+
<br><small class="text-muted"><%= meta.inspect %></small>
|
|
219
278
|
<% end %>
|
|
220
279
|
</td>
|
|
221
280
|
<td>
|
|
@@ -57,11 +57,11 @@
|
|
|
57
57
|
<% end %>
|
|
58
58
|
</td>
|
|
59
59
|
<td style="padding: var(--space-3) var(--space-4); text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--text-primary);">
|
|
60
|
-
<%= error.occurrence_count
|
|
60
|
+
<%= number_with_delimiter(error.occurrence_count) %>
|
|
61
61
|
</td>
|
|
62
62
|
<td style="padding: var(--space-3) var(--space-4); text-align: right; font-variant-numeric: tabular-nums; color: var(--text-secondary);">
|
|
63
63
|
<% if error.user_id %>
|
|
64
|
-
<%= error.user_id %>
|
|
64
|
+
<%= link_to error.user_id, errors_path(user_id: error.user_id, unresolved: '0'), style: "color: inherit; text-decoration: none;", title: "View all errors for user #{error.user_id}" %>
|
|
65
65
|
<% else %>
|
|
66
66
|
<span style="color: var(--text-tertiary);">—</span>
|
|
67
67
|
<% end %>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<!-- Instance Variables (captured from receiver object at raise time) -->
|
|
2
2
|
<% if RailsErrorDashboard.configuration.enable_instance_variables && error.class.column_names.include?("instance_variables") && error.read_attribute(:instance_variables).present? %>
|
|
3
3
|
<% instance_vars = JSON.parse(error.read_attribute(:instance_variables)) rescue {} %>
|
|
4
|
+
<% instance_vars = {} unless instance_vars.is_a?(Hash) %>
|
|
4
5
|
<% self_class = instance_vars.delete("_self_class") %>
|
|
5
6
|
<% if instance_vars.any? %>
|
|
6
7
|
<div class="card mb-4" id="section-instance-variables">
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<% if RailsErrorDashboard.configuration.enable_llm_observability &&
|
|
2
|
+
RailsErrorDashboard.configuration.enable_breadcrumbs &&
|
|
3
|
+
error.respond_to?(:breadcrumbs) && error.breadcrumbs.present? %>
|
|
4
|
+
<% breadcrumbs = JSON.parse(error.breadcrumbs) rescue [] %>
|
|
5
|
+
<% breadcrumbs = [] unless breadcrumbs.is_a?(Array) %>
|
|
6
|
+
<% llm_summary = RailsErrorDashboard::Services::LlmSummary.call(breadcrumbs) %>
|
|
7
|
+
<% if llm_summary %>
|
|
8
|
+
<div class="card mb-4" id="section-llm-summary">
|
|
9
|
+
<div class="card-header">
|
|
10
|
+
<h5 class="mb-0">
|
|
11
|
+
<i class="bi bi-robot text-info" aria-hidden="true"></i> LLM Calls
|
|
12
|
+
<span class="badge bg-info text-dark"><%= llm_summary[:total_calls] %></span>
|
|
13
|
+
</h5>
|
|
14
|
+
<small class="text-muted">Aggregated from request breadcrumbs</small>
|
|
15
|
+
</div>
|
|
16
|
+
<div class="card-body">
|
|
17
|
+
<div class="row text-center mb-3">
|
|
18
|
+
<div class="col-4">
|
|
19
|
+
<div class="fs-4 fw-bold"><%= llm_summary[:total_calls] %></div>
|
|
20
|
+
<small class="text-muted">Calls</small>
|
|
21
|
+
</div>
|
|
22
|
+
<div class="col-4">
|
|
23
|
+
<div class="fs-4 fw-bold"><%= number_with_delimiter(llm_summary[:total_tokens]) %></div>
|
|
24
|
+
<small class="text-muted">Tokens</small>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="col-4">
|
|
27
|
+
<div class="fs-4 fw-bold">
|
|
28
|
+
<% if llm_summary[:total_cost_usd] > 0 %>
|
|
29
|
+
$<%= "%.4f" % llm_summary[:total_cost_usd] %>
|
|
30
|
+
<% else %>
|
|
31
|
+
<span class="text-muted">--</span>
|
|
32
|
+
<% end %>
|
|
33
|
+
</div>
|
|
34
|
+
<small class="text-muted">Cost</small>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div class="mb-3">
|
|
39
|
+
<div class="d-flex justify-content-between mb-1">
|
|
40
|
+
<small class="text-muted">Input</small>
|
|
41
|
+
<small><strong><%= number_with_delimiter(llm_summary[:total_input_tokens]) %></strong></small>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="d-flex justify-content-between mb-1">
|
|
44
|
+
<small class="text-muted">Output</small>
|
|
45
|
+
<small><strong><%= number_with_delimiter(llm_summary[:total_output_tokens]) %></strong></small>
|
|
46
|
+
</div>
|
|
47
|
+
<% if llm_summary[:total_tool_calls] > 0 %>
|
|
48
|
+
<div class="d-flex justify-content-between mb-1">
|
|
49
|
+
<small class="text-muted">Tool calls</small>
|
|
50
|
+
<small><strong><%= llm_summary[:total_tool_calls] %></strong></small>
|
|
51
|
+
</div>
|
|
52
|
+
<% end %>
|
|
53
|
+
<div class="d-flex justify-content-between mb-1">
|
|
54
|
+
<small class="text-muted">Total time</small>
|
|
55
|
+
<small><strong><%= "%.1f" % llm_summary[:total_duration_ms] %>ms</strong></small>
|
|
56
|
+
</div>
|
|
57
|
+
<% if llm_summary[:error_count] > 0 %>
|
|
58
|
+
<div class="d-flex justify-content-between mb-1">
|
|
59
|
+
<small class="text-muted">Errors</small>
|
|
60
|
+
<small><span class="badge bg-danger"><%= llm_summary[:error_count] %></span></small>
|
|
61
|
+
</div>
|
|
62
|
+
<% end %>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<% if llm_summary[:by_model].any? %>
|
|
66
|
+
<hr class="my-2">
|
|
67
|
+
<small class="text-muted d-block mb-2">By model</small>
|
|
68
|
+
<% llm_summary[:by_model].each do |row| %>
|
|
69
|
+
<div class="d-flex justify-content-between align-items-center mb-1">
|
|
70
|
+
<small style="word-break: break-all;">
|
|
71
|
+
<% if row[:provider].present? %>
|
|
72
|
+
<span class="text-muted"><%= row[:provider] %>·</span>
|
|
73
|
+
<% end %>
|
|
74
|
+
<strong><%= row[:model].presence || "unknown" %></strong>
|
|
75
|
+
</small>
|
|
76
|
+
<small class="text-muted">
|
|
77
|
+
<%= row[:calls] %> · <%= number_with_delimiter(row[:tokens]) %>tok
|
|
78
|
+
<% if row[:cost_usd] > 0 %>
|
|
79
|
+
· $<%= "%.4f" % row[:cost_usd] %>
|
|
80
|
+
<% end %>
|
|
81
|
+
</small>
|
|
82
|
+
</div>
|
|
83
|
+
<% end %>
|
|
84
|
+
<% end %>
|
|
85
|
+
|
|
86
|
+
<% if llm_summary[:error_count] > 0 %>
|
|
87
|
+
<div class="alert alert-danger py-1 px-2 mb-0 mt-2">
|
|
88
|
+
<small>
|
|
89
|
+
<i class="bi bi-exclamation-triangle" aria-hidden="true"></i>
|
|
90
|
+
<%= llm_summary[:error_count] %> failed call<%= llm_summary[:error_count] == 1 ? "" : "s" %> — see breadcrumbs trail for details.
|
|
91
|
+
</small>
|
|
92
|
+
</div>
|
|
93
|
+
<% end %>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
<% end %>
|
|
97
|
+
<% end %>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<!-- Local Variables (captured via TracePoint) -->
|
|
2
2
|
<% if RailsErrorDashboard.configuration.enable_local_variables && error.respond_to?(:local_variables) && error.local_variables.present? %>
|
|
3
3
|
<% local_vars = JSON.parse(error.local_variables) rescue {} %>
|
|
4
|
+
<% local_vars = {} unless local_vars.is_a?(Hash) %>
|
|
4
5
|
<% if local_vars.any? %>
|
|
5
6
|
<div class="card mb-4" id="section-local-variables">
|
|
6
7
|
<div class="card-header">
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<div class="modal fade" id="resolveModal" tabindex="-1" aria-labelledby="resolveModalLabel" aria-hidden="true">
|
|
3
3
|
<div class="modal-dialog">
|
|
4
4
|
<div class="modal-content">
|
|
5
|
-
<%= form_with url: resolve_error_path(error), method: :post
|
|
5
|
+
<%= form_with url: resolve_error_path(error), method: :post do |f| %>
|
|
6
6
|
<div class="modal-header">
|
|
7
7
|
<h5 class="modal-title" id="resolveModalLabel">
|
|
8
8
|
<i class="bi bi-check-circle"></i> Mark Error as Resolved
|
|
@@ -1,32 +1,33 @@
|
|
|
1
1
|
<script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
|
|
2
2
|
window.downloadErrorJSON = function(event) {
|
|
3
3
|
const errorData = {
|
|
4
|
-
id: <%=
|
|
5
|
-
error_type: <%=
|
|
6
|
-
message: <%=
|
|
7
|
-
backtrace: <%=
|
|
8
|
-
occurred_at: <%=
|
|
9
|
-
first_seen_at: <%=
|
|
10
|
-
last_seen_at: <%=
|
|
11
|
-
occurrence_count: <%=
|
|
12
|
-
resolved: <%=
|
|
13
|
-
resolved_at: <%=
|
|
14
|
-
resolved_by_name: <%=
|
|
15
|
-
platform: <%=
|
|
16
|
-
user_id: <%=
|
|
17
|
-
severity: <%=
|
|
18
|
-
priority_level: <%=
|
|
4
|
+
id: <%= js_safe_json(error.id) %>,
|
|
5
|
+
error_type: <%= js_safe_json(error.error_type) %>,
|
|
6
|
+
message: <%= js_safe_json(error.message) %>,
|
|
7
|
+
backtrace: <%= js_safe_json(error.backtrace) %>,
|
|
8
|
+
occurred_at: <%= js_safe_json(error.occurred_at) %>,
|
|
9
|
+
first_seen_at: <%= js_safe_json(error.first_seen_at) %>,
|
|
10
|
+
last_seen_at: <%= js_safe_json(error.last_seen_at) %>,
|
|
11
|
+
occurrence_count: <%= js_safe_json(error.occurrence_count) %>,
|
|
12
|
+
resolved: <%= js_safe_json(error.resolved?) %>,
|
|
13
|
+
resolved_at: <%= js_safe_json(error.resolved_at) %>,
|
|
14
|
+
resolved_by_name: <%= js_safe_json(error.resolved_by_name) %>,
|
|
15
|
+
platform: <%= js_safe_json(error.platform) %>,
|
|
16
|
+
user_id: <%= js_safe_json(error.user_id) %>,
|
|
17
|
+
severity: <%= js_safe_json(error.severity) %>,
|
|
18
|
+
priority_level: <%= js_safe_json(error.priority_level || 0) %>,
|
|
19
19
|
<% if error.respond_to?(:exception_cause) && error.exception_cause.present? %>
|
|
20
|
-
|
|
20
|
+
<% parsed_cause = (JSON.parse(error.exception_cause) rescue nil) %>
|
|
21
|
+
exception_cause: <%= js_safe_json(parsed_cause) %>,
|
|
21
22
|
<% end %>
|
|
22
23
|
<% if error.respond_to?(:app_version) %>
|
|
23
|
-
app_version: <%=
|
|
24
|
+
app_version: <%= js_safe_json(error.app_version) %>,
|
|
24
25
|
<% end %>
|
|
25
26
|
<% if error.respond_to?(:git_commit) %>
|
|
26
|
-
git_commit: <%=
|
|
27
|
+
git_commit: <%= js_safe_json(error.git_commit) %>,
|
|
27
28
|
<% end %>
|
|
28
|
-
created_at: <%=
|
|
29
|
-
updated_at: <%=
|
|
29
|
+
created_at: <%= js_safe_json(error.created_at) %>,
|
|
30
|
+
updated_at: <%= js_safe_json(error.updated_at) %>
|
|
30
31
|
};
|
|
31
32
|
|
|
32
33
|
const jsonString = JSON.stringify(errorData, null, 2);
|