solid_loop 0.0.4 → 0.0.5
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/CHANGELOG.md +445 -0
- data/README.md +305 -4
- data/Rakefile +5 -4
- data/app/controllers/solid_loop/application_controller.rb +6 -0
- data/app/controllers/solid_loop/dashboard_controller.rb +167 -22
- data/app/controllers/solid_loop/events_controller.rb +9 -1
- data/app/controllers/solid_loop/mcp_sessions_controller.rb +12 -0
- data/app/controllers/solid_loop/messages_controller.rb +11 -1
- data/app/controllers/solid_loop/tool_calls_controller.rb +32 -0
- data/app/helpers/solid_loop/application_helper.rb +4 -1
- data/app/helpers/solid_loop/metrics_helper.rb +232 -0
- data/app/jobs/solid_loop/janitor_job.rb +24 -0
- data/app/jobs/solid_loop/llm_completion_job.rb +2 -2
- data/app/models/solid_loop/base.rb +89 -6
- data/app/models/solid_loop/loop.rb +22 -0
- data/app/models/solid_loop/message.rb +33 -7
- data/app/models/solid_loop/tool_call.rb +7 -1
- data/app/services/solid_loop/adapters/native.rb +156 -23
- data/app/services/solid_loop/dialects/anthropic.rb +43 -10
- data/app/services/solid_loop/dialects/gemini.rb +55 -15
- data/app/services/solid_loop/dialects/open_ai.rb +17 -5
- data/app/services/solid_loop/dialects/reasoning_packer.rb +72 -7
- data/app/services/solid_loop/llm_usage_parser/llama.rb +18 -3
- data/app/services/solid_loop/mcp_session_initializer.rb +1 -0
- data/app/services/solid_loop/middlewares/agent_initialization.rb +1 -1
- data/app/services/solid_loop/middlewares/error_handling.rb +5 -1
- data/app/services/solid_loop/middlewares/event_logging.rb +3 -3
- data/app/services/solid_loop/middlewares/message_building.rb +38 -4
- data/app/services/solid_loop/middlewares/response_parsing.rb +19 -3
- data/app/views/layouts/solid_loop/admin.html.erb +134 -23
- data/app/views/solid_loop/dashboard/index.html.erb +330 -41
- data/app/views/solid_loop/events/index.html.erb +17 -1
- data/app/views/solid_loop/loops/index.html.erb +40 -3
- data/app/views/solid_loop/loops/show.html.erb +1 -1
- data/app/views/solid_loop/mcp_sessions/index.html.erb +40 -2
- data/app/views/solid_loop/messages/_message.html.erb +26 -35
- data/app/views/solid_loop/messages/index.html.erb +16 -2
- data/app/views/solid_loop/tool_calls/index.html.erb +18 -3
- data/db/migrate/20260819000100_solid_loop_add_retention_indexes.rb +22 -0
- data/docs/contributing/coverage.md +8 -8
- data/docs/decisions/mcp-server.md +4 -2
- data/docs/decisions/reasoning_persistence.md +120 -4
- data/docs/guides/dialects.md +1 -1
- data/docs/guides/mcp_transports.md +72 -0
- data/docs/validation.md +85 -0
- data/lib/solid_loop/configuration.rb +93 -0
- data/lib/solid_loop/engine.rb +8 -0
- data/lib/solid_loop/janitor.rb +110 -0
- data/lib/solid_loop/mcp/toolset.rb +119 -31
- data/lib/solid_loop/pipeline/builder.rb +31 -10
- data/lib/solid_loop/redaction.rb +26 -0
- data/lib/solid_loop/version.rb +1 -1
- data/lib/solid_loop.rb +47 -0
- metadata +7 -2
- data/lib/tasks/coverage.rake +0 -206
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
<div class="sl-page-header">
|
|
2
|
-
<
|
|
2
|
+
<div>
|
|
3
|
+
<h2>Dashboard</h2>
|
|
4
|
+
<div class="sl-page-header__meta" style="font-size: 0.9em;"><%= @window_label %></div>
|
|
5
|
+
</div>
|
|
3
6
|
<div class="sl-page-header__actions">
|
|
7
|
+
<span id="dash-loading" class="sl-badge sl-badge--processing" style="display: none;">Loading…</span>
|
|
4
8
|
<div class="sl-period-toggle">
|
|
5
9
|
<%= link_to "Hour", dashboard_path(period: :hour),
|
|
6
10
|
class: "sl-btn sl-btn--small #{'sl-btn--primary' if @period == 'hour'}" %>
|
|
@@ -16,28 +20,51 @@
|
|
|
16
20
|
<div class="sl-grid-row">
|
|
17
21
|
<div class="sl-card sl-stat-card">
|
|
18
22
|
<div class="sl-stat-card__label">LLM Calls</div>
|
|
19
|
-
<div id="stat-llm-calls" class="sl-stat-card__value"
|
|
23
|
+
<div id="stat-llm-calls" class="sl-stat-card__value" data-stat>–</div>
|
|
24
|
+
<div class="sl-stat-card__label" style="text-transform: none; font-size: 0.78em;"><%= @window_label %></div>
|
|
20
25
|
</div>
|
|
21
26
|
<div class="sl-card sl-stat-card">
|
|
22
27
|
<div class="sl-stat-card__label">Tool Calls</div>
|
|
23
|
-
<div id="stat-tool-calls" class="sl-stat-card__value"
|
|
28
|
+
<div id="stat-tool-calls" class="sl-stat-card__value" data-stat>–</div>
|
|
29
|
+
<div class="sl-stat-card__label" style="text-transform: none; font-size: 0.78em;"><%= @window_label %></div>
|
|
24
30
|
</div>
|
|
25
|
-
|
|
31
|
+
<%# Hidden until the payload proves there is a non-zero cost to report: on a
|
|
32
|
+
local-inference deployment every cost figure is 0.00 forever, and a tile
|
|
33
|
+
that can only ever read $0.00 is a permanently wasted quarter of the row. %>
|
|
34
|
+
<div class="sl-card sl-stat-card" id="panel-cost-kpi" style="display: none;">
|
|
26
35
|
<div class="sl-stat-card__label">Cost</div>
|
|
27
|
-
<div id="stat-cost" class="sl-stat-card__value sl-stat-card__value--accent"
|
|
36
|
+
<div id="stat-cost" class="sl-stat-card__value sl-stat-card__value--accent" data-stat>–</div>
|
|
37
|
+
<div class="sl-stat-card__label" style="text-transform: none; font-size: 0.78em;"><%= @window_label %></div>
|
|
28
38
|
</div>
|
|
29
39
|
<div class="sl-card sl-stat-card">
|
|
30
40
|
<div class="sl-stat-card__label">Failed Loops</div>
|
|
31
|
-
<div id="stat-failed" class="sl-stat-card__value sl-stat-card__value--error"
|
|
32
|
-
<div id="stat-failed-split" class="sl-stat-card__label" style="text-transform: none;"
|
|
41
|
+
<div id="stat-failed" class="sl-stat-card__value sl-stat-card__value--error" data-stat>–</div>
|
|
42
|
+
<div id="stat-failed-split" class="sl-stat-card__label" style="text-transform: none; font-size: 0.78em;"><%= @window_label %></div>
|
|
33
43
|
</div>
|
|
34
44
|
</div>
|
|
35
45
|
|
|
36
|
-
<div class="sl-card">
|
|
37
|
-
<h3>Cost ($)
|
|
46
|
+
<div class="sl-card" id="panel-cost-chart" style="display: none;">
|
|
47
|
+
<h3>Cost ($) <span style="color: var(--sl-muted-text); font-size: 0.7em; font-weight: normal;"><%= @window_label %></span></h3>
|
|
38
48
|
<div id="chart-cost" class="sl-chart-container"></div>
|
|
39
49
|
</div>
|
|
40
50
|
|
|
51
|
+
<%# The operations row that takes the cost panels' place when cost is always
|
|
52
|
+
zero. Loop status and generation throughput are recorded already but were
|
|
53
|
+
not rendered anywhere in the admin. %>
|
|
54
|
+
<div class="sl-grid-row" id="row-ops" style="display: none;">
|
|
55
|
+
<div class="sl-card sl-stat-card" style="text-align: left;">
|
|
56
|
+
<h3>Loops by status <span style="color: var(--sl-muted-text); font-size: 0.7em; font-weight: normal;"><%= @window_label %></span></h3>
|
|
57
|
+
<div id="panel-loop-statuses" style="margin-top: 14px;"></div>
|
|
58
|
+
</div>
|
|
59
|
+
<div class="sl-card sl-stat-card" style="text-align: left;">
|
|
60
|
+
<h3>Throughput <span style="color: var(--sl-muted-text); font-size: 0.7em; font-weight: normal;"><%= @window_label %></span></h3>
|
|
61
|
+
<div id="panel-throughput" style="margin-top: 14px;"></div>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<%# The three per-time-bucket series sit in one row, same chart type, same
|
|
66
|
+
x-axis, same colour — Tokens used to be a horizontal BarChart in a row of
|
|
67
|
+
its own, so nothing could be lined up against anything. %>
|
|
41
68
|
<div class="sl-grid-row">
|
|
42
69
|
<div class="sl-card sl-stat-card">
|
|
43
70
|
<h3>LLM Calls</h3>
|
|
@@ -47,43 +74,96 @@
|
|
|
47
74
|
<h3>Tool Calls</h3>
|
|
48
75
|
<div id="chart-tool-calls" class="sl-chart-container"></div>
|
|
49
76
|
</div>
|
|
50
|
-
</div>
|
|
51
|
-
|
|
52
|
-
<div class="sl-grid-row">
|
|
53
77
|
<div class="sl-card sl-stat-card">
|
|
54
78
|
<h3>Tokens</h3>
|
|
55
79
|
<div id="chart-tokens" class="sl-chart-container"></div>
|
|
56
80
|
</div>
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div class="sl-card" id="panel-top-agents" style="display: none;">
|
|
84
|
+
<h3>Top Agents by Cost <span style="color: var(--sl-muted-text); font-size: 0.7em; font-weight: normal;"><%= @window_label %></span></h3>
|
|
85
|
+
<div id="chart-top-agents" class="sl-chart-container"></div>
|
|
61
86
|
</div>
|
|
62
87
|
|
|
63
88
|
<script>
|
|
64
89
|
let pollTimer = null;
|
|
90
|
+
let hasRendered = false;
|
|
65
91
|
const period = "<%= @period %>";
|
|
66
92
|
|
|
67
93
|
const colors = {
|
|
68
94
|
accent: '#5288c1',
|
|
69
95
|
success: '#31b545',
|
|
70
96
|
error: '#ff6b6b',
|
|
97
|
+
pending: '#f1c40f',
|
|
71
98
|
text: '#f5f5f5',
|
|
72
99
|
muted: '#7f91a4'
|
|
73
100
|
};
|
|
74
101
|
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
102
|
+
const gridColor = 'rgba(255,255,255,0.08)';
|
|
103
|
+
|
|
104
|
+
// Red is reserved for failure. Every other single-series panel gets ONE
|
|
105
|
+
// colour for the whole series, because Chartkick's `colors` array is applied
|
|
106
|
+
// PER BAR when a chart has a single series: with three colours in it the LLM
|
|
107
|
+
// Calls chart painted 7 PM red and 8 PM blue, so a reader saw an alarm where
|
|
108
|
+
// there was only an hour of the day.
|
|
109
|
+
const seriesColor = {
|
|
110
|
+
llmCalls: colors.accent,
|
|
111
|
+
toolCalls: colors.accent,
|
|
112
|
+
tokens: colors.accent,
|
|
113
|
+
cost: colors.accent,
|
|
114
|
+
topAgents: colors.accent,
|
|
115
|
+
failed: colors.error
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const statusColor = {
|
|
119
|
+
running: colors.accent,
|
|
120
|
+
queued: colors.pending,
|
|
121
|
+
init: colors.muted,
|
|
122
|
+
paused: colors.pending,
|
|
123
|
+
completed: colors.success,
|
|
124
|
+
failed: colors.error
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
function withAlpha(hex, alpha) {
|
|
128
|
+
const value = parseInt(hex.slice(1), 16);
|
|
129
|
+
return 'rgba(' + [(value >> 16) & 255, (value >> 8) & 255, value & 255].join(',') + ',' + alpha + ')';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Fresh object per chart: Chart.js mutates the options it is handed, so a
|
|
133
|
+
// shared literal would leak one panel's axis callbacks into the next.
|
|
134
|
+
function baseLibrary() {
|
|
135
|
+
return {
|
|
136
|
+
maintainAspectRatio: false,
|
|
137
|
+
// Every chart here is single-series; a legend would only restate the
|
|
138
|
+
// panel heading and steal a row of chart height.
|
|
139
|
+
plugins: { legend: { display: false } },
|
|
81
140
|
scales: {
|
|
82
|
-
x: {
|
|
83
|
-
|
|
141
|
+
x: {
|
|
142
|
+
ticks: { color: colors.muted, autoSkip: true, maxRotation: 0 },
|
|
143
|
+
grid: { color: gridColor }
|
|
144
|
+
},
|
|
145
|
+
y: {
|
|
146
|
+
beginAtZero: true,
|
|
147
|
+
ticks: { color: colors.muted },
|
|
148
|
+
grid: { color: gridColor }
|
|
149
|
+
}
|
|
84
150
|
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// `dataset` is merged straight onto the Chart.js dataset, which is the only
|
|
155
|
+
// way to pin ONE colour to a whole single-series bar chart — Chartkick's
|
|
156
|
+
// `colors` option deliberately spreads its array across the bars instead.
|
|
157
|
+
function chartOptions(hex, library) {
|
|
158
|
+
return {
|
|
159
|
+
dataset: {
|
|
160
|
+
backgroundColor: withAlpha(hex, 0.55),
|
|
161
|
+
borderColor: hex,
|
|
162
|
+
borderWidth: 1
|
|
163
|
+
},
|
|
164
|
+
library: library || baseLibrary()
|
|
165
|
+
};
|
|
166
|
+
}
|
|
87
167
|
|
|
88
168
|
// Client-side twin of the sl_money helper so the Cost KPI never reads
|
|
89
169
|
// "$0.00" for a real sub-cent run: 2 dp above a cent, more precision below.
|
|
@@ -98,6 +178,39 @@
|
|
|
98
178
|
return '$' + value.toFixed(precision);
|
|
99
179
|
}
|
|
100
180
|
|
|
181
|
+
function trimTrailingZero(value) {
|
|
182
|
+
return value.toFixed(1).replace(/\.0$/, '');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Token counts run to six figures; raw "111401" on an axis tick is unreadable
|
|
186
|
+
// and makes the axis wide enough to squeeze the plot.
|
|
187
|
+
function compactNumber(value) {
|
|
188
|
+
const n = Number(value) || 0;
|
|
189
|
+
const abs = Math.abs(n);
|
|
190
|
+
if (abs >= 1e9) return trimTrailingZero(n / 1e9) + 'B';
|
|
191
|
+
if (abs >= 1e6) return trimTrailingZero(n / 1e6) + 'M';
|
|
192
|
+
if (abs >= 1e3) return trimTrailingZero(n / 1e3) + 'k';
|
|
193
|
+
return String(n);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function withSeparators(value) {
|
|
197
|
+
return (Number(value) || 0).toLocaleString();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function tokensLibrary() {
|
|
201
|
+
const library = baseLibrary();
|
|
202
|
+
library.scales.y.ticks.callback = function (value) { return compactNumber(value); };
|
|
203
|
+
library.plugins.tooltip = {
|
|
204
|
+
callbacks: {
|
|
205
|
+
label: function (context) {
|
|
206
|
+
const value = context.parsed.y;
|
|
207
|
+
return compactNumber(value) + ' tokens (' + withSeparators(value) + ')';
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
return library;
|
|
212
|
+
}
|
|
213
|
+
|
|
101
214
|
// Shared bucket formatting so bar/column charts label their time buckets the
|
|
102
215
|
// same human way the Cost time-axis line chart does, and stay chronological.
|
|
103
216
|
//
|
|
@@ -124,8 +237,10 @@
|
|
|
124
237
|
}
|
|
125
238
|
|
|
126
239
|
// Turn an ISO-keyed hash into chronologically sorted [humanLabel, value] pairs.
|
|
240
|
+
// The server already emits every bucket in the window, zeros included, so all
|
|
241
|
+
// panels come out on the same x-axis.
|
|
127
242
|
function bucketSeries(hash, period) {
|
|
128
|
-
return Object.keys(hash)
|
|
243
|
+
return Object.keys(hash || {})
|
|
129
244
|
.map(function (k) { return { t: new Date(k), v: hash[k] }; })
|
|
130
245
|
.sort(function (a, b) { return a.t - b.t; })
|
|
131
246
|
.map(function (row) { return [formatBucketLabel(row.t, period), row.v]; });
|
|
@@ -133,11 +248,135 @@
|
|
|
133
248
|
|
|
134
249
|
// Same treatment for a Chartkick multi-series ([{name, data: {iso: v}}]).
|
|
135
250
|
function bucketMultiSeries(series, period) {
|
|
136
|
-
return series.map(function (s) {
|
|
251
|
+
return (series || []).map(function (s) {
|
|
137
252
|
return { name: s.name, data: bucketSeries(s.data, period) };
|
|
138
253
|
});
|
|
139
254
|
}
|
|
140
255
|
|
|
256
|
+
// --- loading / empty states -------------------------------------------
|
|
257
|
+
//
|
|
258
|
+
// Before this, an unresolved (or failed) XHR left the KPI tiles reading "-"
|
|
259
|
+
// and four 300px boxes completely blank, which is exactly what a period with
|
|
260
|
+
// no activity looked like. Now the two states say which one they are.
|
|
261
|
+
|
|
262
|
+
function noticeMarkup(text, muted) {
|
|
263
|
+
return '<div style="height: 100%; display: flex; align-items: center; ' +
|
|
264
|
+
'justify-content: center; color: ' + (muted ? colors.muted : colors.error) +
|
|
265
|
+
'; font-size: 0.9em;">' + text + '</div>';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function setBusy(busy) {
|
|
269
|
+
document.getElementById('dash-loading').style.display = busy ? '' : 'none';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function showFirstLoad() {
|
|
273
|
+
document.querySelectorAll('[data-stat]').forEach(function (el) {
|
|
274
|
+
el.textContent = '…';
|
|
275
|
+
el.style.opacity = '0.45';
|
|
276
|
+
});
|
|
277
|
+
document.querySelectorAll('.sl-chart-container').forEach(function (el) {
|
|
278
|
+
el.innerHTML = noticeMarkup('Loading…', true);
|
|
279
|
+
});
|
|
280
|
+
document.getElementById('panel-loop-statuses').innerHTML = noticeMarkup('Loading…', true);
|
|
281
|
+
document.getElementById('panel-throughput').innerHTML = noticeMarkup('Loading…', true);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function showError() {
|
|
285
|
+
document.querySelectorAll('[data-stat]').forEach(function (el) {
|
|
286
|
+
el.textContent = '–';
|
|
287
|
+
el.style.opacity = '1';
|
|
288
|
+
});
|
|
289
|
+
document.querySelectorAll('.sl-chart-container').forEach(function (el) {
|
|
290
|
+
el.innerHTML = noticeMarkup('Could not load stats', false);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function setStat(id, text) {
|
|
295
|
+
const el = document.getElementById(id);
|
|
296
|
+
el.textContent = text;
|
|
297
|
+
el.style.opacity = '1';
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function show(el, visible) {
|
|
301
|
+
if (el) el.style.display = visible ? '' : 'none';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function hasSignal(pairs) {
|
|
305
|
+
return pairs.some(function (pair) { return Number(pair[1]) !== 0; });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Draw, or say plainly that there is nothing to draw. An all-zero series is
|
|
309
|
+
// rendered as the empty state rather than as a flat line at the baseline,
|
|
310
|
+
// which reads identically to a chart that failed to load.
|
|
311
|
+
function drawChart(containerId, pairs, draw) {
|
|
312
|
+
const el = document.getElementById(containerId);
|
|
313
|
+
if (!el) return;
|
|
314
|
+
|
|
315
|
+
if (!pairs.length || !hasSignal(pairs)) {
|
|
316
|
+
el.innerHTML = noticeMarkup('No data for this period', true);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
el.innerHTML = '';
|
|
320
|
+
draw();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// --- ops panels --------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
function statBox(label, value, unit, footnote, color) {
|
|
326
|
+
return '<div style="flex: 1; min-width: 0; background: var(--sl-bg-color); ' +
|
|
327
|
+
'border: 1px solid var(--sl-border-color); border-radius: 8px; padding: 10px 12px;">' +
|
|
328
|
+
'<div style="color: ' + colors.muted + '; font-size: 0.75em; text-transform: uppercase;">' + label + '</div>' +
|
|
329
|
+
'<div style="font-size: 1.6em; font-weight: bold; margin: 2px 0; color: ' + (color || colors.text) + ';">' +
|
|
330
|
+
value + (unit ? '<span style="font-size: 0.5em; font-weight: normal; color: ' + colors.muted + ';"> ' + unit + '</span>' : '') +
|
|
331
|
+
'</div>' +
|
|
332
|
+
'<div style="color: ' + colors.muted + '; font-size: 0.75em;">' + footnote + '</div>' +
|
|
333
|
+
'</div>';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function renderLoopStatuses(statuses) {
|
|
337
|
+
const names = Object.keys(statuses || {});
|
|
338
|
+
const total = names.reduce(function (sum, name) { return sum + Number(statuses[name]); }, 0);
|
|
339
|
+
const el = document.getElementById('panel-loop-statuses');
|
|
340
|
+
|
|
341
|
+
if (!names.length || total === 0) {
|
|
342
|
+
el.innerHTML = '<div style="color: ' + colors.muted + '; font-size: 0.9em;">No loops in this period</div>';
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
el.innerHTML = '<div style="display: flex; gap: 8px; flex-wrap: wrap;">' +
|
|
347
|
+
names.map(function (name) {
|
|
348
|
+
const count = Number(statuses[name]);
|
|
349
|
+
return '<div style="flex: 1; min-width: 78px; text-align: center; background: var(--sl-bg-color); ' +
|
|
350
|
+
'border: 1px solid var(--sl-border-color); border-radius: 8px; padding: 10px 6px; ' +
|
|
351
|
+
(count === 0 ? 'opacity: 0.45;' : '') + '">' +
|
|
352
|
+
'<div style="font-size: 1.5em; font-weight: bold; color: ' + statusColor[name] + ';">' + count + '</div>' +
|
|
353
|
+
'<span class="sl-badge" style="background: ' + withAlpha(statusColor[name], 0.2) +
|
|
354
|
+
'; color: ' + statusColor[name] + ';">' + name + '</span>' +
|
|
355
|
+
'</div>';
|
|
356
|
+
}).join('') + '</div>';
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function renderThroughput(throughput) {
|
|
360
|
+
const t = throughput || {};
|
|
361
|
+
const el = document.getElementById('panel-throughput');
|
|
362
|
+
|
|
363
|
+
if (!t.tps_samples && !t.ttft_samples) {
|
|
364
|
+
el.innerHTML = '<div style="color: ' + colors.muted + '; font-size: 0.9em;">No generation measured in this period</div>';
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const samples = function (n) { return n === 1 ? 'over 1 message' : 'over ' + withSeparators(n) + ' messages'; };
|
|
369
|
+
|
|
370
|
+
el.innerHTML = '<div style="display: flex; gap: 8px;">' +
|
|
371
|
+
statBox('Avg TPS', t.tps === null || t.tps === undefined ? '–' : t.tps,
|
|
372
|
+
'tok/s', samples(t.tps_samples), colors.success) +
|
|
373
|
+
statBox('Avg TTFT', t.ttft === null || t.ttft === undefined ? '–' : t.ttft,
|
|
374
|
+
's', samples(t.ttft_samples), colors.accent) +
|
|
375
|
+
'</div>';
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// --- polling -----------------------------------------------------------
|
|
379
|
+
|
|
141
380
|
document.querySelector('.sl-poll-toggle').addEventListener('click', function () {
|
|
142
381
|
if (pollTimer) {
|
|
143
382
|
clearInterval(pollTimer);
|
|
@@ -153,32 +392,82 @@
|
|
|
153
392
|
});
|
|
154
393
|
|
|
155
394
|
function fetchStats() {
|
|
395
|
+
setBusy(true);
|
|
156
396
|
fetch(`<%= dashboard_stats_path(format: :json) %>?period=${period}`)
|
|
157
397
|
.then(r => r.json())
|
|
158
|
-
.then(data =>
|
|
398
|
+
.then(data => {
|
|
399
|
+
updateCharts(data);
|
|
400
|
+
hasRendered = true;
|
|
401
|
+
})
|
|
402
|
+
.catch(() => { if (!hasRendered) showError(); })
|
|
403
|
+
.finally(() => setBusy(false));
|
|
159
404
|
}
|
|
160
405
|
|
|
161
406
|
function updateCharts(data) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
407
|
+
setStat('stat-llm-calls', data.summary.llm_calls);
|
|
408
|
+
setStat('stat-tool-calls', data.summary.tool_calls);
|
|
409
|
+
setStat('stat-cost', formatMoney(data.summary.cost));
|
|
410
|
+
setStat('stat-failed', data.summary.failed);
|
|
411
|
+
|
|
166
412
|
const splitEl = document.getElementById('stat-failed-split');
|
|
167
413
|
if (data.summary.failed > 0) {
|
|
168
|
-
splitEl.textContent = `${data.summary.failed_agent} agent · ${data.summary.failed_infra} infra`;
|
|
414
|
+
splitEl.textContent = `${data.summary.failed_agent} agent · ${data.summary.failed_infra} infra · ${data.window_label}`;
|
|
169
415
|
} else {
|
|
170
|
-
splitEl.textContent =
|
|
416
|
+
splitEl.textContent = data.window_label;
|
|
171
417
|
}
|
|
172
418
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
419
|
+
// Cost panels only earn their space when something actually cost money.
|
|
420
|
+
// Local llama.cpp inference reports zero forever, and three panels pinned
|
|
421
|
+
// to $0.00 (with a Top Agents axis helpfully scaled to $1) crowd out the
|
|
422
|
+
// numbers that do move. Swap them for loop status and throughput.
|
|
423
|
+
const costPositive = Number(data.summary.cost) > 0;
|
|
424
|
+
show(document.getElementById('panel-cost-kpi'), costPositive);
|
|
425
|
+
show(document.getElementById('panel-cost-chart'), costPositive);
|
|
426
|
+
show(document.getElementById('panel-top-agents'), costPositive);
|
|
427
|
+
show(document.getElementById('row-ops'), !costPositive);
|
|
428
|
+
|
|
429
|
+
if (costPositive) {
|
|
430
|
+
const costPairs = bucketSeries(data.cost, period);
|
|
431
|
+
drawChart('chart-cost', costPairs, function () {
|
|
432
|
+
new Chartkick.LineChart('chart-cost', data.cost,
|
|
433
|
+
Object.assign(chartOptions(seriesColor.cost), { prefix: '$' }));
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const agentPairs = Object.keys(data.top_agents || {})
|
|
437
|
+
.map(function (k) { return [k, data.top_agents[k]]; });
|
|
438
|
+
drawChart('chart-top-agents', agentPairs, function () {
|
|
439
|
+
new Chartkick.BarChart('chart-top-agents', data.top_agents,
|
|
440
|
+
Object.assign(chartOptions(seriesColor.topAgents), { prefix: '$' }));
|
|
441
|
+
});
|
|
442
|
+
} else {
|
|
443
|
+
renderLoopStatuses(data.loop_statuses);
|
|
444
|
+
renderThroughput(data.throughput);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const llmPairs = bucketSeries(data.llm_calls, period);
|
|
448
|
+
drawChart('chart-llm-calls', llmPairs, function () {
|
|
449
|
+
new Chartkick.ColumnChart('chart-llm-calls', llmPairs, chartOptions(seriesColor.llmCalls));
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const toolPairs = bucketSeries(data.tool_calls, period);
|
|
453
|
+
drawChart('chart-tool-calls', toolPairs, function () {
|
|
454
|
+
new Chartkick.ColumnChart('chart-tool-calls', toolPairs, chartOptions(seriesColor.toolCalls));
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// A ColumnChart, like its LLM Calls / Tool Calls siblings: it is the same
|
|
458
|
+
// "value per time bucket" shape, and rendering it on its side made the three
|
|
459
|
+
// panels impossible to compare at a glance.
|
|
460
|
+
const tokenSeries = bucketMultiSeries(data.tokens, period);
|
|
461
|
+
const tokenPairs = tokenSeries.length ? tokenSeries[0].data : [];
|
|
462
|
+
drawChart('chart-tokens', tokenPairs, function () {
|
|
463
|
+
new Chartkick.ColumnChart('chart-tokens', tokenPairs,
|
|
464
|
+
chartOptions(seriesColor.tokens, tokensLibrary()));
|
|
465
|
+
});
|
|
178
466
|
}
|
|
179
467
|
|
|
180
468
|
// Initial load
|
|
181
469
|
document.addEventListener('DOMContentLoaded', () => {
|
|
470
|
+
showFirstLoad();
|
|
182
471
|
fetchStats();
|
|
183
472
|
});
|
|
184
|
-
</script>
|
|
473
|
+
</script>
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
<div class="sl-page-header">
|
|
2
|
+
<h2>Events</h2>
|
|
3
|
+
</div>
|
|
4
|
+
|
|
1
5
|
<div class="sl-card">
|
|
2
6
|
<%= form_with url: events_path, method: :get, class: "sl-page-header__actions", style: "justify-content: flex-start; flex-wrap: wrap;" do |f| %>
|
|
3
7
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
@@ -25,16 +29,28 @@
|
|
|
25
29
|
<th class="sl-table__th">ID</th>
|
|
26
30
|
<th class="sl-table__th">Type</th>
|
|
27
31
|
<th class="sl-table__th">Loop ID</th>
|
|
32
|
+
<th class="sl-table__th">Status</th>
|
|
33
|
+
<th class="sl-table__th">Duration</th>
|
|
28
34
|
<th class="sl-table__th">Created At</th>
|
|
29
35
|
<th class="sl-table__th">Actions</th>
|
|
30
36
|
</tr>
|
|
31
37
|
</thead>
|
|
32
38
|
<tbody>
|
|
33
39
|
<% @events.each do |event| %>
|
|
40
|
+
<% status = sl_event_status(event) %>
|
|
34
41
|
<tr>
|
|
35
42
|
<td class="sl-table__td"><%= event.id %></td>
|
|
36
|
-
<td class="sl-table__td"
|
|
43
|
+
<td class="sl-table__td">
|
|
44
|
+
<span class="sl-badge <%= sl_event_badge_class(event.name) %>"><%= event.name %></span>
|
|
45
|
+
<% if status[:kind] == :error && status[:title].present? %>
|
|
46
|
+
<div class="sl-metric__sub" style="max-width: 420px; white-space: normal;" title="<%= status[:title] %>">
|
|
47
|
+
<%= truncate(status[:title], length: 90) %>
|
|
48
|
+
</div>
|
|
49
|
+
<% end %>
|
|
50
|
+
</td>
|
|
37
51
|
<td class="sl-table__td"><%= link_to event.loop_id, loop_path(event.loop_id) if event.loop_id %></td>
|
|
52
|
+
<td class="sl-table__td"><%= sl_event_status_badge(event) %></td>
|
|
53
|
+
<td class="sl-table__td"><%= sl_duration_cell(event.duration, scale: sl_event_duration_scale(event.name)) %></td>
|
|
38
54
|
<td class="sl-table__td"><%= event.created_at.strftime("%Y-%m-%d %H:%M") %></td>
|
|
39
55
|
<td class="sl-table__td">
|
|
40
56
|
<%= link_to "View", event_path(event), class: "sl-btn sl-btn--small" %>
|
|
@@ -34,8 +34,10 @@
|
|
|
34
34
|
<th class="sl-table__th">ID</th>
|
|
35
35
|
<th class="sl-table__th">Agent</th>
|
|
36
36
|
<th class="sl-table__th">Status</th>
|
|
37
|
-
<th class="sl-table__th">Cost</th>
|
|
38
37
|
<th class="sl-table__th">Steps</th>
|
|
38
|
+
<th class="sl-table__th" title="LLM generation + tool execution (Loop#work_duration)">Work</th>
|
|
39
|
+
<th class="sl-table__th">Tokens</th>
|
|
40
|
+
<th class="sl-table__th" title="Loop row last written — lease renewals, turn commits, status changes">Last Activity</th>
|
|
39
41
|
<th class="sl-table__th">Created At</th>
|
|
40
42
|
<th class="sl-table__th"></th>
|
|
41
43
|
</tr>
|
|
@@ -69,8 +71,43 @@
|
|
|
69
71
|
</div>
|
|
70
72
|
<% end %>
|
|
71
73
|
</td>
|
|
72
|
-
<td class="sl-table__td"><%=
|
|
73
|
-
<td class="sl-table__td"
|
|
74
|
+
<td class="sl-table__td"><span class="sl-metric"><%= loop.step_count %></span></td>
|
|
75
|
+
<td class="sl-table__td">
|
|
76
|
+
<%# Accumulated WORK, not wall clock: a loop that has generated and run
|
|
77
|
+
tools for 90 minutes reads very differently from one that was
|
|
78
|
+
created 90 minutes ago and has done nothing. Deliberately NOT
|
|
79
|
+
severity-coloured — this is a cumulative total, so "large" is what
|
|
80
|
+
a productive loop looks like, unlike a single call's latency. %>
|
|
81
|
+
<span class="sl-metric" title="<%= number_with_precision(loop.work_duration, precision: 3) %>s — generation <%= number_with_precision(loop.duration_generation, precision: 1) %>s + tools <%= number_with_precision(loop.duration_tools, precision: 1) %>s">
|
|
82
|
+
<%= sl_duration(loop.work_duration) %>
|
|
83
|
+
</span>
|
|
84
|
+
</td>
|
|
85
|
+
<td class="sl-table__td">
|
|
86
|
+
<span class="sl-metric"><%= sl_tokens(loop.tokens_total) %></span>
|
|
87
|
+
<%# Cost is folded in here rather than owning a column: under local
|
|
88
|
+
inference every row is $0.00, and a column of zeroes was crowding
|
|
89
|
+
out the numbers that do vary. It reappears the moment a priced
|
|
90
|
+
provider makes it non-zero. %>
|
|
91
|
+
<% if loop.cost.to_f.positive? %>
|
|
92
|
+
<span class="sl-metric__sub"><%= sl_money(loop.cost) %></span>
|
|
93
|
+
<% end %>
|
|
94
|
+
</td>
|
|
95
|
+
<td class="sl-table__td">
|
|
96
|
+
<%# `updated_at`, not the newest message's timestamp. The loop row is
|
|
97
|
+
written on every turn commit AND on every lease renewal
|
|
98
|
+
(LeaseRenewer sets `updated_at` explicitly), so it advances DURING
|
|
99
|
+
a long generation — while messages are only written at turn
|
|
100
|
+
boundaries, and streaming appends use `update_columns`, which skips
|
|
101
|
+
timestamps entirely. It is also already on the row: no join, no
|
|
102
|
+
N+1 on a page that lists 50 loops. %>
|
|
103
|
+
<span class="sl-metric <%= "sl-metric--#{sl_idle_severity(loop)}" if sl_idle_severity(loop) %>"
|
|
104
|
+
title="<%= loop.updated_at.strftime("%Y-%m-%d %H:%M:%S") %>">
|
|
105
|
+
<%= sl_time_ago(loop.updated_at) %>
|
|
106
|
+
</span>
|
|
107
|
+
<% if sl_idle_severity(loop) == :slow %>
|
|
108
|
+
<span class="sl-metric__sub">no activity — likely stalled</span>
|
|
109
|
+
<% end %>
|
|
110
|
+
</td>
|
|
74
111
|
<td class="sl-table__td"><%= loop.created_at.strftime("%Y-%m-%d %H:%M") %></td>
|
|
75
112
|
<td class="sl-table__td" style="text-align: right;">
|
|
76
113
|
<%= link_to "View", loop_path(loop), class: "sl-btn sl-btn--small" %>
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
Tool Time: <span class="sl-message__footer-value"><%= number_with_precision(@loop.duration_tools&.to_f || 0, precision: 2) %>s</span>
|
|
91
91
|
</div>
|
|
92
92
|
<div class="sl-message__footer-item">
|
|
93
|
-
|
|
93
|
+
Work Duration: <span class="sl-message__footer-value"><%= number_with_precision(@loop.work_duration, precision: 2) %>s</span>
|
|
94
94
|
</div>
|
|
95
95
|
<div class="sl-message__footer-item">
|
|
96
96
|
Steps: <span class="sl-message__footer-value"><%= @loop.step_count %></span>
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
<div class="sl-page-header">
|
|
2
|
+
<h2>MCP Sessions</h2>
|
|
3
|
+
</div>
|
|
4
|
+
|
|
1
5
|
<div class="sl-card">
|
|
2
6
|
<%= form_with url: mcp_sessions_path, method: :get, class: "sl-page-header__actions", style: "justify-content: flex-start; flex-wrap: wrap;" do |f| %>
|
|
3
7
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
@@ -20,7 +24,10 @@
|
|
|
20
24
|
<tr>
|
|
21
25
|
<th class="sl-table__th">ID</th>
|
|
22
26
|
<th class="sl-table__th">MCP Name</th>
|
|
27
|
+
<th class="sl-table__th">Loop</th>
|
|
23
28
|
<th class="sl-table__th">Transport</th>
|
|
29
|
+
<th class="sl-table__th">Tools</th>
|
|
30
|
+
<th class="sl-table__th" title="A session is reusable only while its loop can still run">State</th>
|
|
24
31
|
<th class="sl-table__th">Session ID</th>
|
|
25
32
|
<th class="sl-table__th">Created At</th>
|
|
26
33
|
<th class="sl-table__th"></th>
|
|
@@ -28,11 +35,42 @@
|
|
|
28
35
|
</thead>
|
|
29
36
|
<tbody>
|
|
30
37
|
<% @mcp_sessions.each do |session| %>
|
|
38
|
+
<%
|
|
39
|
+
loop_status = @loop_statuses[session.loop_id]
|
|
40
|
+
# "Usable" = the owning loop is not terminal. A completed/failed loop is
|
|
41
|
+
# never resumed, so its session id will never be sent again — the row is
|
|
42
|
+
# history. Everything still init/queued/running/paused will reuse this
|
|
43
|
+
# exact session on its next tool call.
|
|
44
|
+
usable = SolidLoop::Loop::STOPPABLE_STATUSES.include?(loop_status)
|
|
45
|
+
tool_count = @tool_counts[session.id].to_i
|
|
46
|
+
%>
|
|
31
47
|
<tr>
|
|
32
48
|
<td class="sl-table__td"><%= session.id %></td>
|
|
33
|
-
<td class="sl-table__td"><%= session.mcp_name %></td>
|
|
49
|
+
<td class="sl-table__td"><strong><%= session.mcp_name %></strong></td>
|
|
50
|
+
<td class="sl-table__td">
|
|
51
|
+
<% if session.loop_id %>
|
|
52
|
+
<%= link_to "##{session.loop_id}", loop_path(session.loop_id) %>
|
|
53
|
+
<% if loop_status %>
|
|
54
|
+
<span class="sl-metric__sub"><%= loop_status.upcase %></span>
|
|
55
|
+
<% end %>
|
|
56
|
+
<% end %>
|
|
57
|
+
</td>
|
|
34
58
|
<td class="sl-table__td"><%= session.transport_kind %></td>
|
|
35
|
-
<td class="sl-table__td"
|
|
59
|
+
<td class="sl-table__td">
|
|
60
|
+
<% if tool_count.positive? %>
|
|
61
|
+
<span class="sl-metric"><%= tool_count %></span>
|
|
62
|
+
<% else %>
|
|
63
|
+
<span class="sl-metric sl-metric--blank" title="No tools were discovered for this session">none</span>
|
|
64
|
+
<% end %>
|
|
65
|
+
</td>
|
|
66
|
+
<td class="sl-table__td">
|
|
67
|
+
<% if usable %>
|
|
68
|
+
<span class="sl-badge sl-badge--success" title="Loop <%= loop_status %> — this session is still reused for tool calls">LIVE</span>
|
|
69
|
+
<% else %>
|
|
70
|
+
<span class="sl-badge sl-badge--mcp-meta" title="Loop <%= loop_status || "missing" %> — the session will not be used again">CLOSED</span>
|
|
71
|
+
<% end %>
|
|
72
|
+
</td>
|
|
73
|
+
<td class="sl-table__td"><%= sl_copyable_id(session.session_id) %></td>
|
|
36
74
|
<td class="sl-table__td"><%= session.created_at.strftime("%Y-%m-%d %H:%M") %></td>
|
|
37
75
|
<td class="sl-table__td" style="text-align: right; white-space: nowrap;">
|
|
38
76
|
<%= link_to "Inspector", inspector_mcp_session_path(session), class: "sl-btn sl-btn--small sl-btn--primary" %>
|