solid_objects 0.12.1 → 0.13.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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +58 -11
  4. data/docs/adr/0009-realtime-updates.md +5 -1
  5. data/docs/architecture.md +3 -2
  6. data/docs/authorization.md +11 -4
  7. data/docs/correctness.md +3 -3
  8. data/docs/dashboard.md +200 -0
  9. data/docs/database-schema.md +5 -4
  10. data/docs/realtime.md +22 -14
  11. data/docs/roadmap.md +24 -6
  12. data/docs/security.md +7 -7
  13. data/examples/application/README.md +5 -5
  14. data/examples/application/app/actors/chat_room_actor.rb +1 -1
  15. data/examples/application/app/actors/shopping_cart_actor.rb +2 -2
  16. data/lib/solid_objects/actor.rb +1 -1
  17. data/lib/solid_objects/version.rb +1 -1
  18. data/lib/solid_objects/web/action.rb +109 -0
  19. data/lib/solid_objects/web/application.rb +239 -0
  20. data/lib/solid_objects/web/csrf_protection.rb +130 -0
  21. data/lib/solid_objects/web/helpers.rb +227 -0
  22. data/lib/solid_objects/web/paginator.rb +64 -0
  23. data/lib/solid_objects/web/route.rb +55 -0
  24. data/lib/solid_objects/web/router.rb +46 -0
  25. data/lib/solid_objects/web/statistics.rb +78 -0
  26. data/lib/solid_objects/web.rb +222 -0
  27. data/sig/generated/lib/solid_objects/web/action.rbs +78 -0
  28. data/sig/generated/lib/solid_objects/web/application.rbs +50 -0
  29. data/sig/generated/lib/solid_objects/web/csrf_protection.rbs +55 -0
  30. data/sig/generated/lib/solid_objects/web/helpers.rbs +118 -0
  31. data/sig/generated/lib/solid_objects/web/paginator.rbs +54 -0
  32. data/sig/generated/lib/solid_objects/web/route.rbs +45 -0
  33. data/sig/generated/lib/solid_objects/web/router.rbs +29 -0
  34. data/sig/generated/lib/solid_objects/web/statistics.rbs +46 -0
  35. data/sig/generated/lib/solid_objects/web.rbs +129 -0
  36. data/web/assets/javascripts/application.js +118 -0
  37. data/web/assets/javascripts/charts.js +190 -0
  38. data/web/assets/stylesheets/application.css +527 -0
  39. data/web/views/_messages.erb +29 -0
  40. data/web/views/_navigation.erb +15 -0
  41. data/web/views/_paging.erb +17 -0
  42. data/web/views/_status_filter.erb +8 -0
  43. data/web/views/_summary.erb +39 -0
  44. data/web/views/broadcasts.erb +35 -0
  45. data/web/views/dashboard.erb +128 -0
  46. data/web/views/dead_letter.erb +40 -0
  47. data/web/views/dead_letters.erb +42 -0
  48. data/web/views/effects.erb +35 -0
  49. data/web/views/instance.erb +139 -0
  50. data/web/views/instances.erb +49 -0
  51. data/web/views/layout.erb +22 -0
  52. data/web/views/mailbox.erb +35 -0
  53. data/web/views/message.erb +34 -0
  54. data/web/views/processes.erb +37 -0
  55. data/web/views/reminders.erb +37 -0
  56. metadata +55 -2
@@ -0,0 +1,527 @@
1
+ :root {
2
+ color-scheme: light dark;
3
+
4
+ --page: #f6f7f9;
5
+ --surface: #ffffff;
6
+ --surface-sunken: #f0f2f5;
7
+ --border: #dfe3e8;
8
+ --text: #1b1f24;
9
+ --text-muted: #626d7a;
10
+ --accent: #1f6feb;
11
+ --accent-contrast: #ffffff;
12
+ --danger: #b32d2e;
13
+ --warning: #9a6700;
14
+ --success: #1a7f37;
15
+ --radius: 8px;
16
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
17
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
18
+ }
19
+
20
+ @media (prefers-color-scheme: dark) {
21
+ :root {
22
+ --page: #0d1117;
23
+ --surface: #161b22;
24
+ --surface-sunken: #0b0f14;
25
+ --border: #2b3138;
26
+ --text: #e6edf3;
27
+ --text-muted: #99a3ad;
28
+ --accent: #4493f8;
29
+ --accent-contrast: #0d1117;
30
+ --danger: #f85149;
31
+ --warning: #d29922;
32
+ --success: #3fb950;
33
+ }
34
+ }
35
+
36
+ * {
37
+ box-sizing: border-box;
38
+ }
39
+
40
+ body {
41
+ margin: 0;
42
+ background: var(--page);
43
+ color: var(--text);
44
+ font-family: var(--font);
45
+ font-size: 14px;
46
+ line-height: 1.5;
47
+ }
48
+
49
+ a {
50
+ color: var(--accent);
51
+ text-decoration: none;
52
+ }
53
+
54
+ a:hover {
55
+ text-decoration: underline;
56
+ }
57
+
58
+ code,
59
+ pre {
60
+ font-family: var(--mono);
61
+ font-size: 12px;
62
+ }
63
+
64
+ /* Navigation */
65
+
66
+ .bar {
67
+ display: flex;
68
+ align-items: center;
69
+ gap: 24px;
70
+ padding: 0 24px;
71
+ background: var(--surface);
72
+ border-bottom: 1px solid var(--border);
73
+ position: sticky;
74
+ top: 0;
75
+ z-index: 10;
76
+ flex-wrap: wrap;
77
+ }
78
+
79
+ .brand {
80
+ display: flex;
81
+ align-items: center;
82
+ gap: 10px;
83
+ padding: 14px 0;
84
+ color: var(--text);
85
+ font-weight: 600;
86
+ }
87
+
88
+ .brand:hover {
89
+ text-decoration: none;
90
+ }
91
+
92
+ .brand-mark {
93
+ display: grid;
94
+ place-items: center;
95
+ width: 28px;
96
+ height: 28px;
97
+ border-radius: var(--radius);
98
+ background: var(--accent);
99
+ color: var(--accent-contrast);
100
+ font-size: 12px;
101
+ font-weight: 700;
102
+ letter-spacing: 0.5px;
103
+ }
104
+
105
+ .tabs {
106
+ display: flex;
107
+ gap: 4px;
108
+ flex: 1;
109
+ flex-wrap: wrap;
110
+ }
111
+
112
+ .tab {
113
+ padding: 14px 10px;
114
+ color: var(--text-muted);
115
+ border-bottom: 2px solid transparent;
116
+ white-space: nowrap;
117
+ }
118
+
119
+ .tab:hover {
120
+ color: var(--text);
121
+ text-decoration: none;
122
+ }
123
+
124
+ .tab-current {
125
+ color: var(--text);
126
+ border-bottom-color: var(--accent);
127
+ font-weight: 600;
128
+ }
129
+
130
+ .bar-right {
131
+ display: flex;
132
+ align-items: center;
133
+ gap: 12px;
134
+ }
135
+
136
+ .environment {
137
+ padding: 3px 8px;
138
+ border: 1px solid var(--border);
139
+ border-radius: 999px;
140
+ color: var(--text-muted);
141
+ font-size: 11px;
142
+ text-transform: uppercase;
143
+ letter-spacing: 0.6px;
144
+ }
145
+
146
+ .poll-toggle {
147
+ padding: 5px 12px;
148
+ border: 1px solid var(--border);
149
+ border-radius: 999px;
150
+ background: transparent;
151
+ color: var(--text-muted);
152
+ font: inherit;
153
+ font-size: 12px;
154
+ cursor: pointer;
155
+ }
156
+
157
+ .poll-toggle[aria-pressed="true"] {
158
+ background: var(--accent);
159
+ border-color: var(--accent);
160
+ color: var(--accent-contrast);
161
+ }
162
+
163
+ /* Layout */
164
+
165
+ .page {
166
+ max-width: 1280px;
167
+ margin: 0 auto;
168
+ padding: 24px;
169
+ display: flex;
170
+ flex-direction: column;
171
+ gap: 20px;
172
+ }
173
+
174
+ .summary {
175
+ display: grid;
176
+ grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
177
+ gap: 1px;
178
+ background: var(--border);
179
+ border: 1px solid var(--border);
180
+ border-radius: var(--radius);
181
+ overflow: hidden;
182
+ }
183
+
184
+ .metric {
185
+ display: flex;
186
+ flex-direction: column;
187
+ gap: 2px;
188
+ padding: 12px 16px;
189
+ background: var(--surface);
190
+ color: var(--text);
191
+ }
192
+
193
+ .metric:hover {
194
+ background: var(--surface-sunken);
195
+ text-decoration: none;
196
+ }
197
+
198
+ .metric-label {
199
+ color: var(--text-muted);
200
+ font-size: 11px;
201
+ text-transform: uppercase;
202
+ letter-spacing: 0.6px;
203
+ }
204
+
205
+ .metric-value {
206
+ font-size: 20px;
207
+ font-weight: 600;
208
+ font-variant-numeric: tabular-nums;
209
+ }
210
+
211
+ .metric-alert .metric-value {
212
+ color: var(--danger);
213
+ }
214
+
215
+ .cards {
216
+ display: grid;
217
+ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
218
+ gap: 16px;
219
+ }
220
+
221
+ .charts {
222
+ display: grid;
223
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
224
+ gap: 16px;
225
+ }
226
+
227
+ .chart-wide {
228
+ grid-column: 1 / -1;
229
+ }
230
+
231
+ /* Chart.js measures a responsive canvas against its parent and writes the
232
+ canvas size back. The parent therefore needs a height of its own: sizing the
233
+ canvas instead leaves the library measuring a box it just resized, and the
234
+ chart creeps larger on every redraw.
235
+
236
+ Taking the canvas out of flow closes that loop for good. However the library
237
+ sizes it, it cannot change the height of the box being measured. */
238
+ .chart-frame {
239
+ position: relative;
240
+ height: 240px;
241
+ width: 100%;
242
+ }
243
+
244
+ .chart-frame canvas {
245
+ position: absolute;
246
+ inset: 0;
247
+ }
248
+
249
+ .card,
250
+ .panel {
251
+ background: var(--surface);
252
+ border: 1px solid var(--border);
253
+ border-radius: var(--radius);
254
+ padding: 16px 20px;
255
+ }
256
+
257
+ .card h2,
258
+ .panel h2 {
259
+ margin: 0 0 12px;
260
+ font-size: 13px;
261
+ text-transform: uppercase;
262
+ letter-spacing: 0.7px;
263
+ color: var(--text-muted);
264
+ }
265
+
266
+ .panel h3 {
267
+ margin: 20px 0 8px;
268
+ font-size: 13px;
269
+ color: var(--text-muted);
270
+ }
271
+
272
+ .panel-head {
273
+ display: flex;
274
+ align-items: center;
275
+ justify-content: space-between;
276
+ gap: 16px;
277
+ flex-wrap: wrap;
278
+ }
279
+
280
+ .panel-head h2 {
281
+ margin: 0;
282
+ font-size: 16px;
283
+ text-transform: none;
284
+ letter-spacing: 0;
285
+ color: var(--text);
286
+ }
287
+
288
+ .actions {
289
+ display: flex;
290
+ align-items: center;
291
+ gap: 10px;
292
+ }
293
+
294
+ .actions form {
295
+ margin: 0;
296
+ }
297
+
298
+ .note {
299
+ margin: 12px 0 0;
300
+ padding: 10px 12px;
301
+ border-left: 3px solid var(--border);
302
+ background: var(--surface-sunken);
303
+ color: var(--text-muted);
304
+ font-size: 12px;
305
+ }
306
+
307
+ .note-error {
308
+ border-left-color: var(--danger);
309
+ color: var(--danger);
310
+ }
311
+
312
+ .empty {
313
+ margin: 0;
314
+ padding: 20px 0;
315
+ color: var(--text-muted);
316
+ }
317
+
318
+ /* Data */
319
+
320
+ dl {
321
+ margin: 0;
322
+ display: grid;
323
+ gap: 8px;
324
+ }
325
+
326
+ /* Several attributes sit side by side, so each one stacks its label over its
327
+ value. A label-left value-right row would put one value against the next
328
+ label and read as a single phrase. */
329
+ .attributes {
330
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
331
+ gap: 8px 24px;
332
+ margin: 16px 0;
333
+ }
334
+
335
+ .attributes > div {
336
+ flex-direction: column;
337
+ align-items: flex-start;
338
+ gap: 2px;
339
+ }
340
+
341
+ .attributes dd {
342
+ text-align: left;
343
+ }
344
+
345
+ dl > div {
346
+ display: flex;
347
+ justify-content: space-between;
348
+ gap: 12px;
349
+ border-bottom: 1px solid var(--border);
350
+ padding-bottom: 6px;
351
+ }
352
+
353
+ dt {
354
+ color: var(--text-muted);
355
+ }
356
+
357
+ dd {
358
+ margin: 0;
359
+ font-variant-numeric: tabular-nums;
360
+ text-align: right;
361
+ word-break: break-word;
362
+ }
363
+
364
+ /* Card values are counts and durations, and "4 min 44 s" reads wrong when the
365
+ unit wraps onto its own line. */
366
+ .card dd {
367
+ white-space: nowrap;
368
+ }
369
+
370
+ table {
371
+ width: 100%;
372
+ border-collapse: collapse;
373
+ font-variant-numeric: tabular-nums;
374
+ }
375
+
376
+ th,
377
+ td {
378
+ padding: 8px 10px;
379
+ text-align: left;
380
+ border-bottom: 1px solid var(--border);
381
+ vertical-align: top;
382
+ }
383
+
384
+ th {
385
+ color: var(--text-muted);
386
+ font-size: 11px;
387
+ text-transform: uppercase;
388
+ letter-spacing: 0.6px;
389
+ font-weight: 600;
390
+ }
391
+
392
+ tbody tr:hover {
393
+ background: var(--surface-sunken);
394
+ }
395
+
396
+ time {
397
+ color: var(--text-muted);
398
+ font-size: 12px;
399
+ }
400
+
401
+ .payload {
402
+ margin: 0;
403
+ padding: 12px;
404
+ background: var(--surface-sunken);
405
+ border: 1px solid var(--border);
406
+ border-radius: var(--radius);
407
+ overflow-x: auto;
408
+ white-space: pre-wrap;
409
+ word-break: break-word;
410
+ }
411
+
412
+ .status {
413
+ display: inline-block;
414
+ padding: 2px 8px;
415
+ border-radius: 999px;
416
+ background: var(--surface-sunken);
417
+ border: 1px solid var(--border);
418
+ color: var(--text-muted);
419
+ font-size: 11px;
420
+ text-transform: uppercase;
421
+ letter-spacing: 0.5px;
422
+ }
423
+
424
+ .status-activated,
425
+ .status-running,
426
+ .status-completed,
427
+ .status-delivered {
428
+ color: var(--success);
429
+ border-color: currentColor;
430
+ }
431
+
432
+ .status-paused,
433
+ .status-draining,
434
+ .status-processing,
435
+ .status-expired {
436
+ color: var(--warning);
437
+ border-color: currentColor;
438
+ }
439
+
440
+ .status-dead {
441
+ color: var(--danger);
442
+ border-color: currentColor;
443
+ }
444
+
445
+ /* Forms */
446
+
447
+ .filters {
448
+ display: flex;
449
+ gap: 6px;
450
+ margin-bottom: 12px;
451
+ flex-wrap: wrap;
452
+ }
453
+
454
+ .filter {
455
+ padding: 4px 12px;
456
+ border: 1px solid var(--border);
457
+ border-radius: 999px;
458
+ color: var(--text-muted);
459
+ font-size: 12px;
460
+ }
461
+
462
+ .filter:hover {
463
+ color: var(--text);
464
+ text-decoration: none;
465
+ }
466
+
467
+ .filter-current {
468
+ background: var(--accent);
469
+ border-color: var(--accent);
470
+ color: var(--accent-contrast);
471
+ }
472
+
473
+ .filters-form {
474
+ display: flex;
475
+ align-items: flex-end;
476
+ gap: 12px;
477
+ margin-bottom: 16px;
478
+ flex-wrap: wrap;
479
+ }
480
+
481
+ .filters-form label {
482
+ display: flex;
483
+ flex-direction: column;
484
+ gap: 4px;
485
+ color: var(--text-muted);
486
+ font-size: 12px;
487
+ }
488
+
489
+ input,
490
+ select,
491
+ button {
492
+ font: inherit;
493
+ padding: 6px 10px;
494
+ border: 1px solid var(--border);
495
+ border-radius: var(--radius);
496
+ background: var(--surface);
497
+ color: var(--text);
498
+ }
499
+
500
+ button {
501
+ cursor: pointer;
502
+ }
503
+
504
+ button:hover {
505
+ border-color: var(--accent);
506
+ color: var(--accent);
507
+ }
508
+
509
+ button.danger:hover {
510
+ border-color: var(--danger);
511
+ color: var(--danger);
512
+ }
513
+
514
+ .paging {
515
+ display: flex;
516
+ align-items: center;
517
+ justify-content: space-between;
518
+ gap: 16px;
519
+ margin-top: 16px;
520
+ color: var(--text-muted);
521
+ font-size: 12px;
522
+ }
523
+
524
+ .paging-links {
525
+ display: flex;
526
+ gap: 12px;
527
+ }
@@ -0,0 +1,29 @@
1
+ <h3><%= h(locals.fetch(:title)) %></h3>
2
+ <% if locals.fetch(:messages).empty? %>
3
+ <p class="empty"><%= h(locals.fetch(:empty, "None.")) %></p>
4
+ <% else %>
5
+ <table>
6
+ <thead>
7
+ <tr>
8
+ <th>Sequence</th>
9
+ <th>Operation</th>
10
+ <th>Delivery</th>
11
+ <th>Attempts</th>
12
+ <th>Available</th>
13
+ <th>Completed</th>
14
+ </tr>
15
+ </thead>
16
+ <tbody>
17
+ <% locals.fetch(:messages).each do |message| %>
18
+ <tr>
19
+ <td><a href="<%= h(path_to("/messages/#{message.id}")) %>"><%= number(message.sequence) %></a></td>
20
+ <td><%= h(message.operation) %></td>
21
+ <td><%= status_label(message.delivery_mode) %></td>
22
+ <td><%= number(message.attempt_count) %> / <%= number(message.max_attempts) %></td>
23
+ <td><%= relative_time(message.available_at) %></td>
24
+ <td><%= relative_time(message.completed_at) %></td>
25
+ </tr>
26
+ <% end %>
27
+ </tbody>
28
+ </table>
29
+ <% end %>
@@ -0,0 +1,15 @@
1
+ <header class="bar">
2
+ <a class="brand" href="<%= h(path_to("/")) %>">
3
+ <span class="brand-mark">SO</span>
4
+ <span class="brand-name">Solid Objects</span>
5
+ </a>
6
+ <nav class="tabs">
7
+ <% tabs.each do |label, path| %>
8
+ <a class="tab<%= current_tab?(path) ? " tab-current" : "" %>" href="<%= h(path_to(path)) %>"><%= h(label) %></a>
9
+ <% end %>
10
+ </nav>
11
+ <div class="bar-right">
12
+ <span class="environment"><%= h(environment_name) %></span>
13
+ <button type="button" class="poll-toggle" data-poll-toggle aria-pressed="false">Live</button>
14
+ </div>
15
+ </header>
@@ -0,0 +1,17 @@
1
+ <nav class="paging" aria-label="Pages">
2
+ <span class="paging-range">
3
+ <% if @paginator.total.zero? %>
4
+ No rows
5
+ <% else %>
6
+ <%= number(@paginator.first_record) %>&ndash;<%= number(@paginator.last_record) %> of <%= number(@paginator.total) %>
7
+ <% end %>
8
+ </span>
9
+ <span class="paging-links">
10
+ <% if @paginator.previous_page %>
11
+ <a href="<%= page_link("page" => @paginator.previous_page) %>">Previous</a>
12
+ <% end %>
13
+ <% if @paginator.next_page %>
14
+ <a href="<%= page_link("page" => @paginator.next_page) %>">Next</a>
15
+ <% end %>
16
+ </span>
17
+ </nav>
@@ -0,0 +1,8 @@
1
+ <nav class="filters" aria-label="Status filter">
2
+ <% if locals.fetch(:all, true) %>
3
+ <a class="filter<%= locals[:selected].nil? ? " filter-current" : "" %>" href="<%= page_link("status" => nil, "page" => nil) %>">All</a>
4
+ <% end %>
5
+ <% locals.fetch(:statuses).each do |status| %>
6
+ <a class="filter<%= (locals[:selected] == status) ? " filter-current" : "" %>" href="<%= page_link("status" => status, "page" => nil) %>"><%= h(status) %></a>
7
+ <% end %>
8
+ </nav>
@@ -0,0 +1,39 @@
1
+ <% summary = statistics.to_h %>
2
+ <section class="summary" aria-label="Runtime totals">
3
+ <a class="metric" href="<%= h(path_to("/instances")) %>">
4
+ <span class="metric-label">Instances</span>
5
+ <span class="metric-value" data-statistic="instances.total"><%= number(summary.dig(:instances, :total)) %></span>
6
+ </a>
7
+ <a class="metric" href="<%= h(path_to("/mailbox")) %>">
8
+ <span class="metric-label">Ready</span>
9
+ <span class="metric-value" data-statistic="mailbox.ready"><%= number(summary.dig(:mailbox, :ready)) %></span>
10
+ </a>
11
+ <a class="metric" href="<%= h(path_to("/mailbox?status=claimed")) %>">
12
+ <span class="metric-label">Claimed</span>
13
+ <span class="metric-value" data-statistic="mailbox.claimed"><%= number(summary.dig(:mailbox, :claimed)) %></span>
14
+ </a>
15
+ <div class="metric">
16
+ <span class="metric-label">Mailbox lag</span>
17
+ <span class="metric-value" data-statistic="mailbox.latency" data-format="duration"><%= duration(summary.dig(:mailbox, :latency)) %></span>
18
+ </div>
19
+ <a class="metric" href="<%= h(path_to("/effects?status=pending")) %>">
20
+ <span class="metric-label">Effects</span>
21
+ <span class="metric-value" data-statistic="effects.pending"><%= number(summary.dig(:effects, :pending)) %></span>
22
+ </a>
23
+ <a class="metric" href="<%= h(path_to("/broadcasts?status=pending")) %>">
24
+ <span class="metric-label">Broadcasts</span>
25
+ <span class="metric-value" data-statistic="broadcasts.pending"><%= number(summary.dig(:broadcasts, :pending)) %></span>
26
+ </a>
27
+ <a class="metric" href="<%= h(path_to("/reminders?status=scheduled")) %>">
28
+ <span class="metric-label">Reminders due</span>
29
+ <span class="metric-value" data-statistic="reminders.due"><%= number(summary.dig(:reminders, :due)) %></span>
30
+ </a>
31
+ <a class="metric metric-alert" href="<%= h(path_to("/dead_letters")) %>">
32
+ <span class="metric-label">Dead letters</span>
33
+ <span class="metric-value" data-statistic="dead_letters.total"><%= number(summary.dig(:dead_letters, :total)) %></span>
34
+ </a>
35
+ <a class="metric" href="<%= h(path_to("/processes?status=running")) %>">
36
+ <span class="metric-label">Processes</span>
37
+ <span class="metric-value" data-statistic="processes.running"><%= number(summary.dig(:processes, :running)) %></span>
38
+ </a>
39
+ </section>
@@ -0,0 +1,35 @@
1
+ <section class="panel">
2
+ <h2>Broadcasts</h2>
3
+ <%= erb(:_status_filter, statuses: SolidObjects::Web::Statistics::BROADCAST_STATUSES, selected: @status) %>
4
+
5
+ <% if @paginator.records.empty? %>
6
+ <p class="empty">No broadcast matches this filter.</p>
7
+ <% else %>
8
+ <table>
9
+ <thead>
10
+ <tr>
11
+ <th>Observable</th>
12
+ <th>Actor</th>
13
+ <th>Status</th>
14
+ <th>State version</th>
15
+ <th>Attempts</th>
16
+ <th>Delivered</th>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ <% @paginator.records.each do |broadcast| %>
21
+ <tr>
22
+ <td><%= h(broadcast.observable_name) %></td>
23
+ <td><a href="<%= h(path_to("/instances/#{broadcast.instance_id}")) %>">instance <%= number(broadcast.instance_id) %></a></td>
24
+ <td><%= status_label(broadcast.status) %></td>
25
+ <td><%= number(broadcast.state_version) %></td>
26
+ <td><%= number(broadcast.attempt_count) %></td>
27
+ <td><%= relative_time(broadcast.delivered_at) %></td>
28
+ </tr>
29
+ <% end %>
30
+ </tbody>
31
+ </table>
32
+ <% end %>
33
+
34
+ <%= erb(:_paging) %>
35
+ </section>