solid_objects 0.12.1 → 0.13.1

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/README.md +69 -13
  4. data/benchmark/idle_polling.rb +98 -0
  5. data/benchmark/support.rb +2 -0
  6. data/docs/adr/0009-realtime-updates.md +5 -1
  7. data/docs/adr/0011-wake-up-strategy.md +6 -0
  8. data/docs/architecture.md +5 -4
  9. data/docs/authorization.md +11 -4
  10. data/docs/benchmarks.md +38 -0
  11. data/docs/correctness.md +3 -3
  12. data/docs/dashboard.md +200 -0
  13. data/docs/database-schema.md +5 -4
  14. data/docs/development.md +1 -0
  15. data/docs/operations.md +24 -0
  16. data/docs/realtime.md +22 -14
  17. data/docs/roadmap.md +32 -11
  18. data/docs/security.md +7 -7
  19. data/examples/application/README.md +5 -5
  20. data/examples/application/app/actors/chat_room_actor.rb +1 -1
  21. data/examples/application/app/actors/shopping_cart_actor.rb +2 -2
  22. data/lib/solid_objects/actor.rb +1 -1
  23. data/lib/solid_objects/broadcast_executor.rb +35 -4
  24. data/lib/solid_objects/configuration.rb +4 -0
  25. data/lib/solid_objects/effect_executor.rb +34 -3
  26. data/lib/solid_objects/polling_backoff.rb +45 -0
  27. data/lib/solid_objects/process_registry.rb +51 -0
  28. data/lib/solid_objects/reminder_scheduler.rb +35 -4
  29. data/lib/solid_objects/version.rb +1 -1
  30. data/lib/solid_objects/wake_up.rb +36 -4
  31. data/lib/solid_objects/wake_up_adapters/postgresql.rb +6 -0
  32. data/lib/solid_objects/wake_up_adapters/redis.rb +25 -3
  33. data/lib/solid_objects/web/action.rb +109 -0
  34. data/lib/solid_objects/web/application.rb +239 -0
  35. data/lib/solid_objects/web/csrf_protection.rb +130 -0
  36. data/lib/solid_objects/web/helpers.rb +227 -0
  37. data/lib/solid_objects/web/paginator.rb +64 -0
  38. data/lib/solid_objects/web/route.rb +55 -0
  39. data/lib/solid_objects/web/router.rb +46 -0
  40. data/lib/solid_objects/web/statistics.rb +78 -0
  41. data/lib/solid_objects/web.rb +222 -0
  42. data/lib/solid_objects/worker.rb +39 -3
  43. data/lib/solid_objects.rb +2 -0
  44. data/sig/generated/lib/solid_objects/broadcast_executor.rbs +7 -0
  45. data/sig/generated/lib/solid_objects/configuration.rbs +6 -2
  46. data/sig/generated/lib/solid_objects/effect_executor.rbs +7 -0
  47. data/sig/generated/lib/solid_objects/polling_backoff.rbs +29 -0
  48. data/sig/generated/lib/solid_objects/process_registry.rbs +15 -0
  49. data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +7 -0
  50. data/sig/generated/lib/solid_objects/wake_up.rbs +19 -2
  51. data/sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs +3 -0
  52. data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +17 -2
  53. data/sig/generated/lib/solid_objects/web/action.rbs +78 -0
  54. data/sig/generated/lib/solid_objects/web/application.rbs +50 -0
  55. data/sig/generated/lib/solid_objects/web/csrf_protection.rbs +55 -0
  56. data/sig/generated/lib/solid_objects/web/helpers.rbs +118 -0
  57. data/sig/generated/lib/solid_objects/web/paginator.rbs +54 -0
  58. data/sig/generated/lib/solid_objects/web/route.rbs +45 -0
  59. data/sig/generated/lib/solid_objects/web/router.rbs +29 -0
  60. data/sig/generated/lib/solid_objects/web/statistics.rbs +46 -0
  61. data/sig/generated/lib/solid_objects/web.rbs +129 -0
  62. data/sig/generated/lib/solid_objects/worker.rbs +7 -0
  63. data/web/assets/javascripts/application.js +118 -0
  64. data/web/assets/javascripts/charts.js +190 -0
  65. data/web/assets/stylesheets/application.css +527 -0
  66. data/web/views/_messages.erb +29 -0
  67. data/web/views/_navigation.erb +15 -0
  68. data/web/views/_paging.erb +17 -0
  69. data/web/views/_status_filter.erb +8 -0
  70. data/web/views/_summary.erb +39 -0
  71. data/web/views/broadcasts.erb +35 -0
  72. data/web/views/dashboard.erb +128 -0
  73. data/web/views/dead_letter.erb +40 -0
  74. data/web/views/dead_letters.erb +42 -0
  75. data/web/views/effects.erb +35 -0
  76. data/web/views/instance.erb +139 -0
  77. data/web/views/instances.erb +49 -0
  78. data/web/views/layout.erb +22 -0
  79. data/web/views/mailbox.erb +35 -0
  80. data/web/views/message.erb +34 -0
  81. data/web/views/processes.erb +37 -0
  82. data/web/views/reminders.erb +37 -0
  83. metadata +58 -2
@@ -0,0 +1,190 @@
1
+ // Draws the dashboard charts. Each canvas carries its own data in a
2
+ // data-chart-values attribute, so this file never fetches on load and the page
3
+ // needs no script-src exception for an inline block.
4
+ //
5
+ // Two of the three charts redraw when the Live poller reports new totals. The
6
+ // third counts instances per actor type, which /stats does not carry, so it
7
+ // stays as the page rendered it.
8
+ (function () {
9
+ "use strict";
10
+
11
+ // Every status gets its own colour. Related statuses stay in the same
12
+ // family, because a legend with two identical swatches reads as a defect
13
+ // even when the two statuses genuinely mean the same thing.
14
+ var STATUS_COLORS = {
15
+ pending: "#d29922",
16
+ paused: "#e07b39",
17
+ processing: "#4493f8",
18
+ scheduled: "#7c72ff",
19
+ completed: "#3fb950",
20
+ delivered: "#2ea8a0",
21
+ dead: "#f85149"
22
+ };
23
+
24
+ var SERIES_COLOR = "#4493f8";
25
+ var charts = {};
26
+
27
+ function palette() {
28
+ var styles = window.getComputedStyle(document.body);
29
+ return {
30
+ text: styles.getPropertyValue("--text-muted").trim() || "#626d7a",
31
+ grid: styles.getPropertyValue("--border").trim() || "#dfe3e8"
32
+ };
33
+ }
34
+
35
+ function values(canvas) {
36
+ try {
37
+ return JSON.parse(canvas.getAttribute("data-chart-values"));
38
+ } catch (error) {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function baseOptions(colors) {
44
+ return {
45
+ responsive: true,
46
+ maintainAspectRatio: false,
47
+ animation: { duration: 200 },
48
+ plugins: { legend: { display: false } },
49
+ scales: {
50
+ x: { ticks: { color: colors.text }, grid: { color: colors.grid } },
51
+ y: {
52
+ beginAtZero: true,
53
+ ticks: { color: colors.text, precision: 0 },
54
+ grid: { color: colors.grid }
55
+ }
56
+ }
57
+ };
58
+ }
59
+
60
+ function barChart(canvas, data, colors, options) {
61
+ var labels = Object.keys(data);
62
+ var settings = baseOptions(colors);
63
+ if (options && options.horizontal) settings.indexAxis = "y";
64
+
65
+ return new window.Chart(canvas, {
66
+ type: "bar",
67
+ data: {
68
+ labels: labels,
69
+ datasets: [
70
+ {
71
+ data: labels.map(function (label) {
72
+ return data[label];
73
+ }),
74
+ backgroundColor: SERIES_COLOR,
75
+ borderRadius: 4,
76
+ maxBarThickness: 36
77
+ }
78
+ ]
79
+ },
80
+ options: settings
81
+ });
82
+ }
83
+
84
+ // One dataset per status so each row shows only the statuses its subsystem
85
+ // actually has; a subsystem without a status contributes zero to it.
86
+ function stackedChart(canvas, data, colors) {
87
+ var rows = Object.keys(data);
88
+ var statuses = [];
89
+ rows.forEach(function (row) {
90
+ Object.keys(data[row]).forEach(function (status) {
91
+ if (statuses.indexOf(status) === -1) statuses.push(status);
92
+ });
93
+ });
94
+
95
+ var settings = baseOptions(colors);
96
+ settings.indexAxis = "y";
97
+ settings.plugins.legend = { display: true, labels: { color: colors.text } };
98
+ settings.scales.x.stacked = true;
99
+ settings.scales.y.stacked = true;
100
+
101
+ return new window.Chart(canvas, {
102
+ type: "bar",
103
+ data: {
104
+ labels: rows,
105
+ datasets: statuses.map(function (status) {
106
+ return {
107
+ label: status,
108
+ backgroundColor: STATUS_COLORS[status] || SERIES_COLOR,
109
+ borderRadius: 2,
110
+ data: rows.map(function (row) {
111
+ return data[row][status] || 0;
112
+ })
113
+ };
114
+ })
115
+ },
116
+ options: settings
117
+ });
118
+ }
119
+
120
+ function build(canvas) {
121
+ var name = canvas.getAttribute("data-chart");
122
+ var data = values(canvas);
123
+ if (!data) return;
124
+
125
+ var colors = palette();
126
+ if (name === "work_by_status") {
127
+ charts[name] = stackedChart(canvas, data, colors);
128
+ return;
129
+ }
130
+
131
+ charts[name] = barChart(canvas, data, colors, {
132
+ horizontal: name === "instances_by_type"
133
+ });
134
+ }
135
+
136
+ function replace(chart, data) {
137
+ chart.data.datasets.forEach(function (dataset) {
138
+ if (!dataset.label) {
139
+ dataset.data = chart.data.labels.map(function (label) {
140
+ return data[label] || 0;
141
+ });
142
+ return;
143
+ }
144
+
145
+ dataset.data = chart.data.labels.map(function (row) {
146
+ return (data[row] || {})[dataset.label] || 0;
147
+ });
148
+ });
149
+ chart.update();
150
+ }
151
+
152
+ function refresh(statistics) {
153
+ if (charts.mailbox_depth) {
154
+ replace(charts.mailbox_depth, {
155
+ Ready: statistics.mailbox.ready,
156
+ Due: statistics.mailbox.due,
157
+ Claimed: statistics.mailbox.claimed
158
+ });
159
+ }
160
+
161
+ if (charts.work_by_status) {
162
+ replace(charts.work_by_status, {
163
+ Effects: statistics.effects,
164
+ Broadcasts: statistics.broadcasts,
165
+ Reminders: statistics.reminders
166
+ });
167
+ }
168
+ }
169
+
170
+ function start() {
171
+ if (!window.Chart) return;
172
+
173
+ var canvases = document.querySelectorAll("[data-chart]");
174
+ Array.prototype.forEach.call(canvases, build);
175
+
176
+ document.addEventListener("solid-objects:statistics", function (event) {
177
+ try {
178
+ refresh(event.detail);
179
+ } catch (error) {
180
+ // A payload missing a section leaves the drawn chart alone.
181
+ }
182
+ });
183
+ }
184
+
185
+ if (document.readyState === "loading") {
186
+ document.addEventListener("DOMContentLoaded", start, { once: true });
187
+ } else {
188
+ start();
189
+ }
190
+ })();
@@ -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
+ }