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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +445 -0
  3. data/README.md +305 -4
  4. data/Rakefile +5 -4
  5. data/app/controllers/solid_loop/application_controller.rb +6 -0
  6. data/app/controllers/solid_loop/dashboard_controller.rb +167 -22
  7. data/app/controllers/solid_loop/events_controller.rb +9 -1
  8. data/app/controllers/solid_loop/mcp_sessions_controller.rb +12 -0
  9. data/app/controllers/solid_loop/messages_controller.rb +11 -1
  10. data/app/controllers/solid_loop/tool_calls_controller.rb +32 -0
  11. data/app/helpers/solid_loop/application_helper.rb +4 -1
  12. data/app/helpers/solid_loop/metrics_helper.rb +232 -0
  13. data/app/jobs/solid_loop/janitor_job.rb +24 -0
  14. data/app/jobs/solid_loop/llm_completion_job.rb +2 -2
  15. data/app/models/solid_loop/base.rb +89 -6
  16. data/app/models/solid_loop/loop.rb +22 -0
  17. data/app/models/solid_loop/message.rb +33 -7
  18. data/app/models/solid_loop/tool_call.rb +7 -1
  19. data/app/services/solid_loop/adapters/native.rb +156 -23
  20. data/app/services/solid_loop/dialects/anthropic.rb +43 -10
  21. data/app/services/solid_loop/dialects/gemini.rb +55 -15
  22. data/app/services/solid_loop/dialects/open_ai.rb +17 -5
  23. data/app/services/solid_loop/dialects/reasoning_packer.rb +72 -7
  24. data/app/services/solid_loop/llm_usage_parser/llama.rb +18 -3
  25. data/app/services/solid_loop/mcp_session_initializer.rb +1 -0
  26. data/app/services/solid_loop/middlewares/agent_initialization.rb +1 -1
  27. data/app/services/solid_loop/middlewares/error_handling.rb +5 -1
  28. data/app/services/solid_loop/middlewares/event_logging.rb +3 -3
  29. data/app/services/solid_loop/middlewares/message_building.rb +38 -4
  30. data/app/services/solid_loop/middlewares/response_parsing.rb +19 -3
  31. data/app/views/layouts/solid_loop/admin.html.erb +134 -23
  32. data/app/views/solid_loop/dashboard/index.html.erb +330 -41
  33. data/app/views/solid_loop/events/index.html.erb +17 -1
  34. data/app/views/solid_loop/loops/index.html.erb +40 -3
  35. data/app/views/solid_loop/loops/show.html.erb +1 -1
  36. data/app/views/solid_loop/mcp_sessions/index.html.erb +40 -2
  37. data/app/views/solid_loop/messages/_message.html.erb +26 -35
  38. data/app/views/solid_loop/messages/index.html.erb +16 -2
  39. data/app/views/solid_loop/tool_calls/index.html.erb +18 -3
  40. data/db/migrate/20260819000100_solid_loop_add_retention_indexes.rb +22 -0
  41. data/docs/contributing/coverage.md +8 -8
  42. data/docs/decisions/mcp-server.md +4 -2
  43. data/docs/decisions/reasoning_persistence.md +120 -4
  44. data/docs/guides/dialects.md +1 -1
  45. data/docs/guides/mcp_transports.md +72 -0
  46. data/docs/validation.md +85 -0
  47. data/lib/solid_loop/configuration.rb +93 -0
  48. data/lib/solid_loop/engine.rb +8 -0
  49. data/lib/solid_loop/janitor.rb +110 -0
  50. data/lib/solid_loop/mcp/toolset.rb +119 -31
  51. data/lib/solid_loop/pipeline/builder.rb +31 -10
  52. data/lib/solid_loop/redaction.rb +26 -0
  53. data/lib/solid_loop/version.rb +1 -1
  54. data/lib/solid_loop.rb +47 -0
  55. metadata +7 -2
  56. data/lib/tasks/coverage.rake +0 -206
@@ -1,6 +1,10 @@
1
1
  <div class="sl-page-header">
2
- <h2>Dashboard</h2>
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">-</div>
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">-</div>
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
- <div class="sl-card sl-stat-card">
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">-</div>
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">-</div>
32
- <div id="stat-failed-split" class="sl-stat-card__label" style="text-transform: none;">-</div>
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 ($)</h3>
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
- <div class="sl-card sl-stat-card">
58
- <h3>Top Agents by Cost</h3>
59
- <div id="chart-top-agents" class="sl-chart-container"></div>
60
- </div>
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 chartOptions = {
76
- colors: [colors.accent, colors.success, colors.error],
77
- library: {
78
- plugins: {
79
- legend: { labels: { color: colors.text } }
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: { ticks: { color: colors.muted }, grid: { color: 'rgba(255,255,255,0.1)' } },
83
- y: { ticks: { color: colors.muted }, grid: { color: 'rgba(255,255,255,0.1)' } }
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 => updateCharts(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
- document.getElementById('stat-llm-calls').textContent = data.summary.llm_calls;
163
- document.getElementById('stat-tool-calls').textContent = data.summary.tool_calls;
164
- document.getElementById('stat-cost').textContent = formatMoney(data.summary.cost);
165
- document.getElementById('stat-failed').textContent = data.summary.failed;
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
- new Chartkick.LineChart('chart-cost', data.cost, { ...chartOptions, prefix: '$' });
174
- new Chartkick.ColumnChart('chart-llm-calls', bucketSeries(data.llm_calls, period), chartOptions);
175
- new Chartkick.ColumnChart('chart-tool-calls', bucketSeries(data.tool_calls, period), chartOptions);
176
- new Chartkick.BarChart('chart-tokens', bucketMultiSeries(data.tokens, period), chartOptions);
177
- new Chartkick.BarChart('chart-top-agents', data.top_agents, { ...chartOptions, prefix: '$' });
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"><span class="sl-badge sl-badge--processing"><%= event.name %></span></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"><%= sl_money(loop.cost) %></td>
73
- <td class="sl-table__td"><%= loop.step_count %></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
- Total Duration: <span class="sl-message__footer-value"><%= number_with_precision(@loop.duration_total&.to_f || 0, precision: 2) %>s</span>
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"><%= session.session_id %></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" %>