rubyrlm 0.1.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 +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE +21 -0
- data/README.md +300 -0
- data/bin/rubyrlm +168 -0
- data/lib/rubyrlm/backends/base.rb +9 -0
- data/lib/rubyrlm/backends/gemini_rest.rb +317 -0
- data/lib/rubyrlm/client.rb +643 -0
- data/lib/rubyrlm/completion.rb +71 -0
- data/lib/rubyrlm/errors.rb +9 -0
- data/lib/rubyrlm/logger/jsonl_logger.rb +27 -0
- data/lib/rubyrlm/pricing.rb +88 -0
- data/lib/rubyrlm/prompts/system_prompt.rb +108 -0
- data/lib/rubyrlm/protocol/action_parser.rb +84 -0
- data/lib/rubyrlm/repl/code_validator.rb +113 -0
- data/lib/rubyrlm/repl/docker_repl/container_manager.rb +158 -0
- data/lib/rubyrlm/repl/docker_repl/host_rpc_server.rb +164 -0
- data/lib/rubyrlm/repl/docker_repl/protocol.rb +26 -0
- data/lib/rubyrlm/repl/docker_repl.rb +190 -0
- data/lib/rubyrlm/repl/execution_result.rb +41 -0
- data/lib/rubyrlm/repl/local_repl.rb +476 -0
- data/lib/rubyrlm/sub_call_cache.rb +47 -0
- data/lib/rubyrlm/version.rb +3 -0
- data/lib/rubyrlm/web/app.rb +41 -0
- data/lib/rubyrlm/web/public/css/components.css +649 -0
- data/lib/rubyrlm/web/public/css/design-system.css +1396 -0
- data/lib/rubyrlm/web/public/js/app.js +1016 -0
- data/lib/rubyrlm/web/public/js/components/charts.js +68 -0
- data/lib/rubyrlm/web/public/js/components/context-inspector.js +94 -0
- data/lib/rubyrlm/web/public/js/components/exec-chain.js +105 -0
- data/lib/rubyrlm/web/public/js/components/kpi-dashboard.js +187 -0
- data/lib/rubyrlm/web/public/js/components/query-panel.js +335 -0
- data/lib/rubyrlm/web/public/js/components/recursion-tree.js +83 -0
- data/lib/rubyrlm/web/public/js/components/session-list.js +160 -0
- data/lib/rubyrlm/web/public/js/components/step-navigator.js +129 -0
- data/lib/rubyrlm/web/public/js/components/timeline.js +281 -0
- data/lib/rubyrlm/web/public/js/lib/animation.js +46 -0
- data/lib/rubyrlm/web/public/js/lib/chart-renderer.js +116 -0
- data/lib/rubyrlm/web/public/js/lib/diagram-renderer.js +233 -0
- data/lib/rubyrlm/web/public/js/lib/sse-client.js +94 -0
- data/lib/rubyrlm/web/public/js/lib/theme-manager.js +39 -0
- data/lib/rubyrlm/web/public/js/utils.js +57 -0
- data/lib/rubyrlm/web/routes/api.rb +129 -0
- data/lib/rubyrlm/web/routes/pages.rb +365 -0
- data/lib/rubyrlm/web/routes/sse.rb +95 -0
- data/lib/rubyrlm/web/services/event_broadcaster.rb +36 -0
- data/lib/rubyrlm/web/services/export_service.rb +903 -0
- data/lib/rubyrlm/web/services/query_service.rb +221 -0
- data/lib/rubyrlm/web/services/session_loader.rb +356 -0
- data/lib/rubyrlm/web/services/streaming_logger.rb +22 -0
- data/lib/rubyrlm.rb +18 -0
- metadata +208 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Charts component - session-level charts
|
|
2
|
+
|
|
3
|
+
const Charts = {
|
|
4
|
+
renderSessionCharts(session) {
|
|
5
|
+
// Render a per-iteration token usage stacked bar chart
|
|
6
|
+
const iterations = session.iterations || [];
|
|
7
|
+
|
|
8
|
+
// Gather per-iteration usage data
|
|
9
|
+
const usageData = [];
|
|
10
|
+
iterations.forEach(it => {
|
|
11
|
+
const d = it.data || it;
|
|
12
|
+
if (d.usage && (d.usage.prompt_tokens || d.usage.candidate_tokens)) {
|
|
13
|
+
usageData.push({
|
|
14
|
+
label: 'Iter ' + (d.iteration || usageData.length + 1),
|
|
15
|
+
prompt: d.usage.prompt_tokens || 0,
|
|
16
|
+
candidate: d.usage.candidate_tokens || 0
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
if (usageData.length === 0) return;
|
|
22
|
+
|
|
23
|
+
// Create or reuse the canvas element inside the usage-summary section
|
|
24
|
+
const summaryDiv = document.getElementById('usage-summary');
|
|
25
|
+
if (!summaryDiv) return;
|
|
26
|
+
|
|
27
|
+
let chartWrapper = document.getElementById('session-token-chart-wrapper');
|
|
28
|
+
if (!chartWrapper) {
|
|
29
|
+
chartWrapper = document.createElement('div');
|
|
30
|
+
chartWrapper.id = 'session-token-chart-wrapper';
|
|
31
|
+
chartWrapper.className = 'chart-container chart-container--small';
|
|
32
|
+
chartWrapper.style.marginTop = '1rem';
|
|
33
|
+
|
|
34
|
+
const canvas = document.createElement('canvas');
|
|
35
|
+
canvas.id = 'session-token-chart';
|
|
36
|
+
chartWrapper.appendChild(canvas);
|
|
37
|
+
summaryDiv.appendChild(chartWrapper);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Render stacked bar chart via ChartRenderer
|
|
41
|
+
ChartRenderer.bar('session-token-chart', {
|
|
42
|
+
stacked: true,
|
|
43
|
+
data: {
|
|
44
|
+
labels: usageData.map(d => d.label),
|
|
45
|
+
datasets: [
|
|
46
|
+
{
|
|
47
|
+
label: 'Prompt Tokens',
|
|
48
|
+
data: usageData.map(d => d.prompt),
|
|
49
|
+
backgroundColor: '#10b981',
|
|
50
|
+
stack: 'tokens'
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
label: 'Candidate Tokens',
|
|
54
|
+
data: usageData.map(d => d.candidate),
|
|
55
|
+
backgroundColor: '#3b82f6',
|
|
56
|
+
stack: 'tokens'
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
options: {
|
|
61
|
+
scales: {
|
|
62
|
+
x: { stacked: true },
|
|
63
|
+
y: { stacked: true }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Context data inspector - tree view for exploring context
|
|
2
|
+
|
|
3
|
+
const ContextInspector = {
|
|
4
|
+
render(containerId, data) {
|
|
5
|
+
const container = document.getElementById(containerId);
|
|
6
|
+
if (!container) return;
|
|
7
|
+
container.textContent = '';
|
|
8
|
+
|
|
9
|
+
if (data == null) {
|
|
10
|
+
container.textContent = 'No context data';
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const tree = document.createElement('div');
|
|
15
|
+
tree.className = 'context-tree';
|
|
16
|
+
this.buildTree(tree, data, 0);
|
|
17
|
+
container.appendChild(tree);
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
buildTree(parent, data, depth) {
|
|
21
|
+
if (depth > 5) {
|
|
22
|
+
const item = document.createElement('div');
|
|
23
|
+
item.className = 'context-tree__item';
|
|
24
|
+
item.style.setProperty('--depth', depth);
|
|
25
|
+
item.textContent = '...';
|
|
26
|
+
parent.appendChild(item);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (typeof data === 'object' && data !== null) {
|
|
31
|
+
const entries = Array.isArray(data)
|
|
32
|
+
? data.map((v, i) => [i, v])
|
|
33
|
+
: Object.entries(data);
|
|
34
|
+
|
|
35
|
+
entries.forEach(([key, value]) => {
|
|
36
|
+
const item = document.createElement('div');
|
|
37
|
+
item.className = 'context-tree__item';
|
|
38
|
+
item.style.setProperty('--depth', depth);
|
|
39
|
+
|
|
40
|
+
const isExpandable = typeof value === 'object' && value !== null;
|
|
41
|
+
|
|
42
|
+
if (isExpandable) {
|
|
43
|
+
const toggle = document.createElement('span');
|
|
44
|
+
toggle.className = 'context-tree__toggle';
|
|
45
|
+
toggle.textContent = '\u25B6';
|
|
46
|
+
item.appendChild(toggle);
|
|
47
|
+
|
|
48
|
+
const keySpan = document.createElement('span');
|
|
49
|
+
keySpan.className = 'context-tree__key';
|
|
50
|
+
keySpan.textContent = key;
|
|
51
|
+
item.appendChild(keySpan);
|
|
52
|
+
|
|
53
|
+
const typeSpan = document.createElement('span');
|
|
54
|
+
typeSpan.className = 'context-tree__type';
|
|
55
|
+
typeSpan.textContent = Array.isArray(value)
|
|
56
|
+
? ' Array[' + value.length + ']'
|
|
57
|
+
: ' Object{' + Object.keys(value).length + '}';
|
|
58
|
+
item.appendChild(typeSpan);
|
|
59
|
+
|
|
60
|
+
const childContainer = document.createElement('div');
|
|
61
|
+
childContainer.style.display = 'none';
|
|
62
|
+
|
|
63
|
+
toggle.addEventListener('click', () => {
|
|
64
|
+
const isOpen = childContainer.style.display !== 'none';
|
|
65
|
+
childContainer.style.display = isOpen ? 'none' : 'block';
|
|
66
|
+
toggle.textContent = isOpen ? '\u25B6' : '\u25BC';
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
parent.appendChild(item);
|
|
70
|
+
this.buildTree(childContainer, value, depth + 1);
|
|
71
|
+
parent.appendChild(childContainer);
|
|
72
|
+
} else {
|
|
73
|
+
const keySpan = document.createElement('span');
|
|
74
|
+
keySpan.className = 'context-tree__key';
|
|
75
|
+
keySpan.textContent = key + ': ';
|
|
76
|
+
item.appendChild(keySpan);
|
|
77
|
+
|
|
78
|
+
const valueSpan = document.createElement('span');
|
|
79
|
+
valueSpan.className = 'context-tree__value';
|
|
80
|
+
valueSpan.textContent = truncate(String(value), 100);
|
|
81
|
+
item.appendChild(valueSpan);
|
|
82
|
+
|
|
83
|
+
parent.appendChild(item);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
} else {
|
|
87
|
+
const item = document.createElement('div');
|
|
88
|
+
item.className = 'context-tree__item';
|
|
89
|
+
item.style.setProperty('--depth', depth);
|
|
90
|
+
item.textContent = String(data);
|
|
91
|
+
parent.appendChild(item);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Exec chain Mermaid flowchart component
|
|
2
|
+
|
|
3
|
+
const ExecChain = {
|
|
4
|
+
async render(session) {
|
|
5
|
+
const iterations = session.iterations || [];
|
|
6
|
+
if (iterations.length === 0) return;
|
|
7
|
+
|
|
8
|
+
const definition = this.buildDefinition(session);
|
|
9
|
+
await DiagramRenderer.render('exec-chain-diagram', definition);
|
|
10
|
+
this.setupClickHandlers();
|
|
11
|
+
},
|
|
12
|
+
|
|
13
|
+
setupClickHandlers() {
|
|
14
|
+
const container = document.getElementById('exec-chain-diagram');
|
|
15
|
+
if (!container) return;
|
|
16
|
+
|
|
17
|
+
// Mermaid creates SVG nodes with class 'node'
|
|
18
|
+
const nodes = container.querySelectorAll('.node');
|
|
19
|
+
nodes.forEach(node => {
|
|
20
|
+
node.style.cursor = 'pointer';
|
|
21
|
+
node.addEventListener('click', () => {
|
|
22
|
+
// Extract sequence number from node ID
|
|
23
|
+
const id = node.id || '';
|
|
24
|
+
const match = id.match(/N(\d+)/);
|
|
25
|
+
if (match) {
|
|
26
|
+
const stepId = 'step-' + match[1];
|
|
27
|
+
const target = document.getElementById(stepId);
|
|
28
|
+
if (target) {
|
|
29
|
+
// Switch to timeline view if in flow view
|
|
30
|
+
if (App.currentView === 'flow') {
|
|
31
|
+
setView('timeline');
|
|
32
|
+
}
|
|
33
|
+
setTimeout(() => {
|
|
34
|
+
target.scrollIntoView({ behavior: 'smooth' });
|
|
35
|
+
target.classList.add('timeline-card--expanded');
|
|
36
|
+
// Flash highlight
|
|
37
|
+
target.style.boxShadow = '0 0 0 2px var(--color-accent)';
|
|
38
|
+
setTimeout(() => { target.style.boxShadow = ''; }, 2000);
|
|
39
|
+
}, 100);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
buildDefinition(session) {
|
|
47
|
+
const iterations = session.iterations || [];
|
|
48
|
+
const prompt = session.run_start?.prompt || 'Query';
|
|
49
|
+
let lines = ['graph TD'];
|
|
50
|
+
|
|
51
|
+
lines.push(' S["' + this.escape(truncate(prompt, 40)) + '"]');
|
|
52
|
+
|
|
53
|
+
iterations.forEach((it, i) => {
|
|
54
|
+
const d = it.data || it;
|
|
55
|
+
const sequence = i + 1
|
|
56
|
+
const nodeId = 'N' + sequence;
|
|
57
|
+
const isSubmit = d.action === 'final' || d.action === 'forced_final';
|
|
58
|
+
const isError = !isSubmit && d.execution && !d.execution.ok;
|
|
59
|
+
|
|
60
|
+
if (isSubmit) {
|
|
61
|
+
const label = this.escape(truncate(d.answer || 'Final', 30));
|
|
62
|
+
lines.push(' ' + nodeId + '["' + label + '"]');
|
|
63
|
+
lines.push(' style ' + nodeId + ' fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd');
|
|
64
|
+
} else {
|
|
65
|
+
const codeLine = (d.code || '').split('\n')[0];
|
|
66
|
+
const label = this.escape(truncate(codeLine, 35));
|
|
67
|
+
lines.push(' ' + nodeId + '["Exec ' + d.iteration + ': ' + label + '"]');
|
|
68
|
+
|
|
69
|
+
if (isError) {
|
|
70
|
+
lines.push(' style ' + nodeId + ' fill:#3b1010,stroke:#ef4444,color:#fca5a5');
|
|
71
|
+
} else {
|
|
72
|
+
lines.push(' style ' + nodeId + ' fill:#0a2e1a,stroke:#10b981,color:#6ee7b7');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Edges
|
|
77
|
+
const prevId = i === 0 ? 'S' : ('N' + i);
|
|
78
|
+
let edgeLabel = '';
|
|
79
|
+
|
|
80
|
+
if (i > 0) {
|
|
81
|
+
const prevD = iterations[i - 1].data || iterations[i - 1];
|
|
82
|
+
if (prevD.execution) {
|
|
83
|
+
if (!prevD.execution.ok) {
|
|
84
|
+
edgeLabel = this.escape(truncate(prevD.execution.error_class || 'error', 20));
|
|
85
|
+
} else if (prevD.execution.value_preview) {
|
|
86
|
+
edgeLabel = this.escape(truncate(prevD.execution.value_preview, 20));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (edgeLabel) {
|
|
92
|
+
lines.push(' ' + prevId + ' -->|"' + edgeLabel + '"| ' + nodeId);
|
|
93
|
+
} else {
|
|
94
|
+
lines.push(' ' + prevId + ' --> ' + nodeId);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
lines.push(' style S fill:#1a1a1a,stroke:#888,color:#e5e5e5');
|
|
99
|
+
return lines.join('\n');
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
escape(str) {
|
|
103
|
+
return (str || '').replace(/"/g, "'").replace(/[<>{}|]/g, ' ');
|
|
104
|
+
}
|
|
105
|
+
};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// KPI Dashboard component for analytics view
|
|
2
|
+
|
|
3
|
+
const KPIDashboard = {
|
|
4
|
+
async load(days = null) {
|
|
5
|
+
try {
|
|
6
|
+
const url = days && days > 0 ? `/api/analytics?days=${days}` : '/api/analytics';
|
|
7
|
+
const data = await fetchJSON(url);
|
|
8
|
+
this.render(data);
|
|
9
|
+
} catch (err) {
|
|
10
|
+
const grid = document.getElementById('kpi-section');
|
|
11
|
+
grid.textContent = '';
|
|
12
|
+
const errDiv = document.createElement('div');
|
|
13
|
+
errDiv.className = 'node';
|
|
14
|
+
errDiv.textContent = 'Failed to load analytics: ' + err.message;
|
|
15
|
+
grid.appendChild(errDiv);
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
render(data) {
|
|
20
|
+
if (data.total_sessions === 0) {
|
|
21
|
+
const grid = document.getElementById('kpi-section');
|
|
22
|
+
grid.textContent = '';
|
|
23
|
+
const empty = document.createElement('div');
|
|
24
|
+
empty.className = 'main-view__empty';
|
|
25
|
+
empty.style.gridColumn = '1 / -1';
|
|
26
|
+
const icon = document.createElement('i');
|
|
27
|
+
icon.className = 'fa-solid fa-chart-pie';
|
|
28
|
+
empty.appendChild(icon);
|
|
29
|
+
const msg = document.createElement('p');
|
|
30
|
+
msg.textContent = 'No session data available. Run some queries to see analytics.';
|
|
31
|
+
empty.appendChild(msg);
|
|
32
|
+
grid.appendChild(empty);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
this.renderKPIs(data);
|
|
36
|
+
this.renderTokenChart(data);
|
|
37
|
+
this.renderModelChart(data);
|
|
38
|
+
this.renderErrorChart(data);
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
renderKPIs(data) {
|
|
42
|
+
const grid = document.getElementById('kpi-section');
|
|
43
|
+
grid.textContent = '';
|
|
44
|
+
|
|
45
|
+
const kpis = [
|
|
46
|
+
{ label: 'Total Sessions', value: data.total_sessions, format: 'number', status: null, icon: 'fa-list-check' },
|
|
47
|
+
{ label: 'Avg Steps/Session', value: data.avg_iterations_per_session, format: 'decimal', status: null, icon: 'fa-shoe-prints' },
|
|
48
|
+
{ label: 'Total Tokens', value: data.total_tokens, format: 'number', status: null, icon: 'fa-coins' },
|
|
49
|
+
{ label: 'Total Cost', value: data.total_cost, format: 'cost', status: null, icon: 'fa-dollar-sign' },
|
|
50
|
+
{
|
|
51
|
+
label: 'Success Rate', value: data.success_rate, format: 'percent',
|
|
52
|
+
status: data.success_rate >= 90 ? 'healthy' : data.success_rate >= 70 ? 'warning' : 'critical', icon: 'fa-circle-check'
|
|
53
|
+
},
|
|
54
|
+
{ label: 'Avg Latency', value: data.avg_latency_per_iteration, format: 'duration', status: null, icon: 'fa-clock' },
|
|
55
|
+
{
|
|
56
|
+
label: 'Repair Rate', value: data.repair_rate, format: 'percent',
|
|
57
|
+
status: data.repair_rate <= 5 ? 'healthy' : data.repair_rate <= 15 ? 'warning' : 'critical', icon: 'fa-wrench'
|
|
58
|
+
}
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
kpis.forEach((kpi, i) => {
|
|
62
|
+
const card = document.createElement('div');
|
|
63
|
+
card.className = 'kpi-card';
|
|
64
|
+
card.style.setProperty('--i', i);
|
|
65
|
+
card.classList.add('animate-scale');
|
|
66
|
+
|
|
67
|
+
const label = document.createElement('div');
|
|
68
|
+
label.className = 'kpi-card__label';
|
|
69
|
+
if (kpi.icon) {
|
|
70
|
+
const icon = document.createElement('i');
|
|
71
|
+
icon.className = 'fa-solid ' + kpi.icon;
|
|
72
|
+
icon.style.marginRight = '0.5rem';
|
|
73
|
+
label.appendChild(icon);
|
|
74
|
+
}
|
|
75
|
+
label.appendChild(document.createTextNode(kpi.label));
|
|
76
|
+
card.appendChild(label);
|
|
77
|
+
|
|
78
|
+
const value = document.createElement('div');
|
|
79
|
+
value.className = 'kpi-card__value';
|
|
80
|
+
switch (kpi.format) {
|
|
81
|
+
case 'number': value.textContent = formatNumber(kpi.value); break;
|
|
82
|
+
case 'decimal': value.textContent = (kpi.value || 0).toFixed(1); break;
|
|
83
|
+
case 'percent': value.textContent = (kpi.value || 0).toFixed(1) + '%'; break;
|
|
84
|
+
case 'duration': value.textContent = formatDuration(kpi.value); break;
|
|
85
|
+
case 'cost': value.textContent = '$' + (kpi.value || 0).toFixed(4); break;
|
|
86
|
+
default: value.textContent = kpi.value;
|
|
87
|
+
}
|
|
88
|
+
card.appendChild(value);
|
|
89
|
+
|
|
90
|
+
if (kpi.status) {
|
|
91
|
+
const badge = document.createElement('div');
|
|
92
|
+
badge.className = 'kpi-card__status kpi-card__status--' + kpi.status;
|
|
93
|
+
badge.textContent = kpi.status.charAt(0).toUpperCase() + kpi.status.slice(1);
|
|
94
|
+
card.appendChild(badge);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
grid.appendChild(card);
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
renderTokenChart(data) {
|
|
102
|
+
const series = data.time_series || [];
|
|
103
|
+
if (series.length === 0) return;
|
|
104
|
+
|
|
105
|
+
const colors = ChartRenderer.getThemeColors();
|
|
106
|
+
ChartRenderer.bar('token-chart', {
|
|
107
|
+
stacked: true,
|
|
108
|
+
data: {
|
|
109
|
+
labels: series.map(s => s.date),
|
|
110
|
+
datasets: [
|
|
111
|
+
{
|
|
112
|
+
label: 'Prompt Tokens',
|
|
113
|
+
data: series.map(s => s.prompt_tokens || 0),
|
|
114
|
+
backgroundColor: colors.accent + '80'
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
label: 'Candidate Tokens',
|
|
118
|
+
data: series.map(s => s.candidate_tokens || 0),
|
|
119
|
+
backgroundColor: colors.info + '60'
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
label: 'Cached Tokens',
|
|
123
|
+
data: series.map(s => s.cached_content_tokens || 0),
|
|
124
|
+
backgroundColor: colors.success + '60'
|
|
125
|
+
}
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
renderModelChart(data) {
|
|
132
|
+
const models = data.model_breakdown || {};
|
|
133
|
+
const entries = Object.entries(models);
|
|
134
|
+
if (entries.length === 0) return;
|
|
135
|
+
|
|
136
|
+
const palette = ['#10b981', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6'];
|
|
137
|
+
ChartRenderer.pie('model-chart', {
|
|
138
|
+
data: {
|
|
139
|
+
labels: entries.map(([m]) => m),
|
|
140
|
+
datasets: [{
|
|
141
|
+
data: entries.map(([, v]) => v.sessions),
|
|
142
|
+
backgroundColor: entries.map((_, i) => palette[i % palette.length] + 'cc')
|
|
143
|
+
}]
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
renderErrorChart(data) {
|
|
149
|
+
const errors = data.top_error_classes || {};
|
|
150
|
+
const entries = Object.entries(errors);
|
|
151
|
+
if (entries.length === 0) {
|
|
152
|
+
const container = document.getElementById('error-analysis');
|
|
153
|
+
if (container) {
|
|
154
|
+
const msg = document.createElement('div');
|
|
155
|
+
msg.style.cssText = 'padding:2rem;text-align:center;color:var(--color-text-muted);';
|
|
156
|
+
msg.textContent = 'No errors recorded';
|
|
157
|
+
container.querySelector('.chart-container')?.appendChild(msg);
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const colors = ChartRenderer.getThemeColors();
|
|
163
|
+
ChartRenderer.bar('error-chart', {
|
|
164
|
+
data: {
|
|
165
|
+
labels: entries.map(([cls]) => cls),
|
|
166
|
+
datasets: [{
|
|
167
|
+
label: 'Count',
|
|
168
|
+
data: entries.map(([, count]) => count),
|
|
169
|
+
backgroundColor: colors.error + '80'
|
|
170
|
+
}]
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
init() {
|
|
176
|
+
const filter = document.getElementById('kpi-time-filter');
|
|
177
|
+
if (filter) {
|
|
178
|
+
filter.addEventListener('change', (e) => {
|
|
179
|
+
this.load(e.target.value);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
186
|
+
KPIDashboard.init();
|
|
187
|
+
});
|