solid_observer 0.3.0 → 0.4.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +134 -81
  4. data/app/assets/stylesheets/solid_observer/application.css +18 -0
  5. data/app/controllers/solid_observer/cache_dashboard_controller.rb +59 -0
  6. data/app/controllers/solid_observer/cache_operations_controller.rb +24 -0
  7. data/app/controllers/solid_observer/dashboard_controller.rb +44 -1
  8. data/app/controllers/solid_observer/storages_controller.rb +1 -1
  9. data/app/helpers/solid_observer/application_helper.rb +154 -5
  10. data/app/helpers/solid_observer/dashboard_helper.rb +30 -11
  11. data/app/models/solid_observer/cache_event.rb +15 -0
  12. data/app/models/solid_observer/cache_metric.rb +14 -0
  13. data/app/models/solid_observer/storage_info.rb +4 -1
  14. data/app/views/layouts/solid_observer/application.html.erb +144 -17
  15. data/app/views/solid_observer/cache_dashboard/_charts.html.erb +40 -0
  16. data/app/views/solid_observer/cache_dashboard/_recent_events.html.erb +34 -0
  17. data/app/views/solid_observer/cache_dashboard/_summary.html.erb +39 -0
  18. data/app/views/solid_observer/cache_dashboard/index.html.erb +62 -0
  19. data/app/views/solid_observer/cache_operations/_confirm_clear.html.erb +6 -0
  20. data/app/views/solid_observer/cache_operations/index.html.erb +60 -0
  21. data/app/views/solid_observer/dashboard/index.html.erb +34 -4
  22. data/app/views/solid_observer/jobs/show.html.erb +3 -3
  23. data/app/views/solid_observer/storages/show.html.erb +64 -32
  24. data/bin/quality_gate +1 -1
  25. data/config/routes.rb +5 -0
  26. data/db/migrate/20260601000001_create_solid_observer_cache_events.rb +22 -0
  27. data/db/migrate/20260601000002_create_solid_observer_cache_metrics.rb +18 -0
  28. data/db/migrate/20260602000001_add_component_to_solid_observer_storage_infos.rb +8 -0
  29. data/lib/generators/solid_observer/templates/initializer.rb.tt +2 -1
  30. data/lib/solid_observer/cache_event_buffer.rb +53 -0
  31. data/lib/solid_observer/cache_subscriber.rb +47 -0
  32. data/lib/solid_observer/cli/storage.rb +16 -13
  33. data/lib/solid_observer/configuration.rb +22 -3
  34. data/lib/solid_observer/engine.rb +44 -7
  35. data/lib/solid_observer/services/cache_operations.rb +115 -0
  36. data/lib/solid_observer/services/cache_stats.rb +329 -0
  37. data/lib/solid_observer/services/cleanup_storage.rb +18 -2
  38. data/lib/solid_observer/services/database_size.rb +13 -8
  39. data/lib/solid_observer/services/flush_cache_event_buffer.rb +54 -0
  40. data/lib/solid_observer/services/record_cache_event.rb +142 -0
  41. data/lib/solid_observer/services/record_cache_metric.rb +74 -0
  42. data/lib/solid_observer/services/storage_info_snapshot.rb +128 -0
  43. data/lib/solid_observer/version.rb +1 -1
  44. data/lib/tasks/solid_observer.rake +29 -0
  45. metadata +23 -1
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "dashboard_helper"
4
+
3
5
  module SolidObserver
4
6
  module ApplicationHelper
7
+ include SolidObserver::DashboardHelper
8
+
5
9
  STATUS_COLORS = {
6
10
  "completed" => "success",
7
11
  "ready" => "success",
@@ -23,6 +27,21 @@ module SolidObserver
23
27
  degraded: {label: "Degraded", tone: "warning"},
24
28
  critical: {label: "Critical", tone: "danger"}
25
29
  }.freeze
30
+ CACHE_OUTCOME_STATES = {
31
+ hit: {label: "Hit", tone: "success"},
32
+ miss: {label: "Miss", tone: "info"},
33
+ error: {label: "Error", tone: "danger"},
34
+ recorded: {label: "Recorded", tone: "recorded"}
35
+ }.freeze
36
+ CACHE_RANGE_LABELS = {
37
+ "15m" => "in last 15m",
38
+ "30m" => "in last 30m",
39
+ "1h" => "in last hour",
40
+ "7h" => "in last 7h",
41
+ "1d" => "in last day",
42
+ "7d" => "in last 7d",
43
+ "14d" => "in last 14d"
44
+ }.freeze
26
45
 
27
46
  def execution_status(execution)
28
47
  ExecutionPresenter.new(execution).status
@@ -41,7 +60,15 @@ module SolidObserver
41
60
  def status_badge(status)
42
61
  status_str = status.to_s
43
62
  color = STATUS_COLORS.fetch(status_str, "default")
44
- content_tag(:span, status_str.humanize, class: "so-badge so-badge--#{color}")
63
+ dot = tag.svg(
64
+ tag.circle(r: 3, cx: 3, cy: 3),
65
+ class: "so-badge__dot",
66
+ viewBox: "0 0 6 6",
67
+ "aria-hidden": "true"
68
+ )
69
+ tag.span(class: "so-badge so-badge--pill so-badge--#{color}") do
70
+ safe_join([dot, status_str.humanize], " ")
71
+ end
45
72
  end
46
73
 
47
74
  def duration_with_semantic(value, event_type)
@@ -53,7 +80,15 @@ module SolidObserver
53
80
  def mode_badge
54
81
  config = SolidObserver.config
55
82
  color = config.persistence_mode? ? "info" : "warning"
56
- content_tag(:span, config.storage_mode.to_s.capitalize, class: "so-badge so-badge--#{color}")
83
+ dot = tag.svg(
84
+ tag.circle(r: 3, cx: 3, cy: 3),
85
+ class: "so-badge__dot",
86
+ viewBox: "0 0 6 6",
87
+ "aria-hidden": "true"
88
+ )
89
+ tag.span(class: "so-badge so-badge--pill so-badge--#{color}") do
90
+ safe_join([dot, config.storage_mode.to_s.capitalize], " ")
91
+ end
57
92
  end
58
93
 
59
94
  def turbo_frame_tag(id, **options, &block)
@@ -73,14 +108,68 @@ module SolidObserver
73
108
  end
74
109
 
75
110
  def stability_badge(stats)
76
- meta = STABILITY_STATES.fetch(stability_state(stats))
77
- dot = tag.svg(tag.circle(r: 3, cx: 3, cy: 3),
78
- class: "so-badge__dot", viewBox: "0 0 6 6", "aria-hidden": "true")
111
+ stability_badge_for(stability_state(stats))
112
+ end
113
+
114
+ def cache_stability_badge(state)
115
+ stability_badge_for(state.to_sym)
116
+ end
117
+
118
+ def cache_ratio_percent(value)
119
+ number_to_percentage(value.to_f * 100, precision: 1, strip_insignificant_zeros: true)
120
+ end
121
+
122
+ def cache_storage_summary(storage_components)
123
+ snapshots = Array(storage_components)
124
+ reason = cache_storage_unavailable_reason(snapshots)
125
+ return {value: "—", subtitle: "— #{reason}"} if reason
126
+
127
+ {
128
+ value: number_to_human_size(cache_storage_total_bytes(snapshots), precision: 1, significant: false, strip_insignificant_zeros: false),
129
+ subtitle: "SolidCache + cache observer"
130
+ }
131
+ end
132
+
133
+ def cache_event_outcome_badge(event)
134
+ meta = cache_event_outcome_meta(event)
135
+ dot = tag.svg(
136
+ tag.circle(r: 3, cx: 3, cy: 3),
137
+ class: "so-badge__dot",
138
+ viewBox: "0 0 6 6",
139
+ "aria-hidden": "true"
140
+ )
141
+
79
142
  tag.span(class: "so-badge so-badge--pill so-badge--#{meta[:tone]}") do
80
143
  safe_join([dot, meta[:label]], " ")
81
144
  end
82
145
  end
83
146
 
147
+ def cache_event_digest(key_digest, visible_chars: 10)
148
+ digest = key_digest.to_s
149
+ return "—" if digest.empty?
150
+ return digest if digest.length <= visible_chars
151
+
152
+ "#{digest.first(visible_chars)}…"
153
+ end
154
+
155
+ def cache_range_label(range_key)
156
+ CACHE_RANGE_LABELS.fetch(range_key.to_s, "in selected range")
157
+ end
158
+
159
+ def cache_stability_detail(stability)
160
+ state = (stability || {})[:state]&.to_sym
161
+ state = :stable unless STABILITY_STATES.key?(state)
162
+
163
+ case state
164
+ when :critical
165
+ critical_cache_stability_detail(stability)
166
+ when :degraded
167
+ degraded_cache_stability_detail(stability)
168
+ else
169
+ "No sampled cache errors or slow events in the selected range"
170
+ end
171
+ end
172
+
84
173
  def stability_detail(stats)
85
174
  failures_24h = stats[:failed_last_24h].to_i
86
175
  return "No failures in the last 24h" if failures_24h.zero?
@@ -91,5 +180,65 @@ module SolidObserver
91
180
  def latest_failure_phrase(timestamp)
92
181
  timestamp ? "#{time_ago_in_words(timestamp)} ago" : "unknown"
93
182
  end
183
+
184
+ def queue_component_enabled?
185
+ SolidObserver.config.solid_queue_enabled?
186
+ end
187
+
188
+ def cache_component_enabled?
189
+ SolidObserver.config.solid_cache_enabled?
190
+ end
191
+
192
+ def dashboard_section_active?(component)
193
+ current_component = @component.presence || "queue"
194
+ controller_name == "dashboard" && current_component == component.to_s
195
+ end
196
+
197
+ private
198
+
199
+ def stability_badge_for(state)
200
+ meta = STABILITY_STATES.fetch(state)
201
+ dot = tag.svg(tag.circle(r: 3, cx: 3, cy: 3),
202
+ class: "so-badge__dot", viewBox: "0 0 6 6", "aria-hidden": "true")
203
+ tag.span(class: "so-badge so-badge--pill so-badge--#{meta[:tone]}") do
204
+ safe_join([dot, meta[:label]], " ")
205
+ end
206
+ end
207
+
208
+ def critical_cache_stability_detail(stability)
209
+ detail = pluralize(stability[:error_count].to_i, "sampled cache error")
210
+ slow_count = stability[:slow_count].to_i
211
+ detail = "#{detail} and #{pluralize(slow_count, "slow event")}" if slow_count.positive?
212
+ "#{detail} in the selected range#{cache_stability_latest_suffix(stability[:latest_recorded_at])}"
213
+ end
214
+
215
+ def degraded_cache_stability_detail(stability)
216
+ detail = pluralize(stability[:slow_count].to_i, "slow sampled cache event")
217
+ "#{detail} in the selected range#{cache_stability_latest_suffix(stability[:latest_recorded_at])}"
218
+ end
219
+
220
+ def cache_stability_latest_suffix(timestamp)
221
+ timestamp ? ", latest #{time_ago_in_words(timestamp)} ago" : ""
222
+ end
223
+
224
+ def cache_storage_total_bytes(snapshots)
225
+ snapshots.sum { |snapshot| snapshot[:db_size_bytes].to_i }
226
+ end
227
+
228
+ def cache_storage_unavailable_reason(snapshots)
229
+ return "Storage snapshot unavailable" unless snapshots.size == 2
230
+
231
+ snapshots.find { |snapshot| !snapshot[:available] }&.[](:unavailable_reason)
232
+ end
233
+
234
+ def cache_event_outcome_meta(event)
235
+ hit = event.hit
236
+
237
+ return CACHE_OUTCOME_STATES.fetch(:error) if event.error_class.present?
238
+ return CACHE_OUTCOME_STATES.fetch(:hit) if hit == true
239
+ return CACHE_OUTCOME_STATES.fetch(:miss) if hit == false
240
+
241
+ CACHE_OUTCOME_STATES.fetch(:recorded)
242
+ end
94
243
  end
95
244
  end
@@ -9,17 +9,36 @@ module SolidObserver
9
9
  def spark_points(series, width: SVG_W, height: SVG_H)
10
10
  return "" if series.blank?
11
11
 
12
- t_min = series.first[:t]
13
- t_max = series.last[:t]
14
- v_max = [series.max_by { |point| point[:v] }[:v], 1].max
15
- time_span = t_max - t_min
16
-
17
- series.map { |point|
18
- val = point[:v]
19
- point_x = time_span.zero? ? width / 2.0 : ((point[:t] - t_min).to_f / time_span) * (width - 2) + 1
20
- point_y = height - 1 - (val.to_f / v_max) * (height - 2)
21
- format("%.1f,%.1f", point_x, point_y)
22
- }.join(" ")
12
+ context = build_spark_context(series, width, height)
13
+ series.map { |point| format_spark_point(point: point, context: context) }.join(" ")
14
+ end
15
+
16
+ def build_spark_context(series, width, height)
17
+ first_point = series.first
18
+ last_point = series.last
19
+ min_time = first_point[:t]
20
+
21
+ {
22
+ min_time: min_time,
23
+ time_span: last_point[:t] - min_time,
24
+ max_value: [series.max_by { |point| point[:v] }[:v], 1].max,
25
+ width: width,
26
+ height: height,
27
+ inner_width: width - 2,
28
+ inner_height: height - 2
29
+ }
30
+ end
31
+
32
+ def format_spark_point(point:, context:)
33
+ time_span = context[:time_span]
34
+ coordinate_x = if time_span.zero?
35
+ context[:width] / 2.0
36
+ else
37
+ ((point[:t] - context[:min_time]).to_f / time_span) * context[:inner_width] + 1
38
+ end
39
+
40
+ coordinate_y = context[:height] - 1 - (point[:v].to_f / context[:max_value]) * context[:inner_height]
41
+ format("%.1f,%.1f", coordinate_x, coordinate_y)
23
42
  end
24
43
 
25
44
  RANGE_LABELS = {
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ class CacheEvent < BaseEvent
5
+ self.table_name = "solid_observer_cache_events"
6
+
7
+ validates :event_type, presence: true
8
+ validates :key_digest, presence: true
9
+ validates :recorded_at, presence: true
10
+
11
+ scope :errored, -> { where.not(error_class: nil) }
12
+ scope :slow, ->(threshold = SolidObserver.config.cache_slow_threshold) { where("duration >= ?", threshold) }
13
+ scope :recent, ->(limit = 10) { order(recorded_at: :desc).limit(limit) }
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidObserver
4
+ class CacheMetric < BaseMetric
5
+ self.table_name = "solid_observer_cache_metrics"
6
+ clear_validators!
7
+
8
+ validates :event_type, presence: true, length: {maximum: 64}
9
+ validates :period_start, presence: true
10
+ validates :operations_count, :hits_count, :misses_count, :errors_count,
11
+ numericality: {only_integer: true, greater_than_or_equal_to: 0}
12
+ validates :duration_total, numericality: {greater_than_or_equal_to: 0}
13
+ end
14
+ end
@@ -6,16 +6,19 @@ module SolidObserver
6
6
 
7
7
  MB_TO_BYTES = 1_048_576
8
8
  GB_TO_BYTES = 1_073_741_824
9
+ COMPONENTS = %w[queue_observer cache_observer solid_cache].freeze
9
10
 
10
11
  validates :db_size_bytes, presence: true, numericality: {only_integer: true, greater_than_or_equal_to: 0}
11
12
  validates :event_count, presence: true, numericality: {only_integer: true, greater_than_or_equal_to: 0}
12
13
  validates :recorded_at, presence: true
14
+ validates :component, presence: true, inclusion: {in: COMPONENTS}
13
15
 
14
16
  scope :recent, ->(limit = 10) { order(recorded_at: :desc).limit(limit) }
15
17
  scope :since, ->(time) { where("recorded_at >= ?", time) }
16
18
 
17
- def self.record_snapshot(db_size:, event_count:)
19
+ def self.record_snapshot(db_size:, event_count:, component: "queue_observer")
18
20
  create!(
21
+ component: component,
19
22
  db_size_bytes: db_size || 0,
20
23
  event_count: event_count,
21
24
  recorded_at: Time.current
@@ -46,6 +46,17 @@
46
46
  --so-live-marker: var(--so-success);
47
47
  --so-range-marker: var(--so-info);
48
48
  --so-toolbar-gap: 0.75rem;
49
+
50
+ /* SO-078 badge tokens */
51
+ --so-badge-success-bg: #dcfce7;
52
+ --so-badge-success-text: #166534;
53
+ --so-badge-warning-bg: #fef3c7;
54
+ --so-badge-warning-text: #92400e;
55
+ --so-badge-danger-bg: #fee2e2;
56
+ --so-badge-danger-text: #991b1b;
57
+ --so-badge-info-bg: #dbeafe;
58
+ --so-badge-info-text: #1e40af;
59
+ --so-badge-neutral-text: #525252;
49
60
  }
50
61
 
51
62
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -93,6 +104,22 @@
93
104
  .so-sidebar__nav a.active {
94
105
  background: var(--so-sidebar-active-bg);
95
106
  color: var(--so-sidebar-active-text);
107
+ font-weight: 600;
108
+ }
109
+
110
+ .so-sidebar__nav a:focus-visible {
111
+ outline: 2px solid var(--so-info);
112
+ outline-offset: 2px;
113
+ box-shadow: 0 0 0 3px var(--so-focus-ring);
114
+ }
115
+
116
+ .so-sidebar__section {
117
+ font-size: 0.7rem;
118
+ text-transform: uppercase;
119
+ letter-spacing: 0.08em;
120
+ color: var(--so-text-muted);
121
+ padding: 0.5rem 0.75rem 0.25rem;
122
+ margin: 0.5rem 0.75rem 0.25rem;
96
123
  }
97
124
 
98
125
  .so-sidebar__mode {
@@ -123,6 +150,8 @@
123
150
 
124
151
  /* SO-067 Metric card anatomy — separated value/suffix/range-copy */
125
152
  .so-metric { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.15rem; }
153
+ .so-metric .so-card__label,
154
+ .so-metric .so-card__subtitle { flex-basis: 100%; }
126
155
  .so-metric__value { font-size: 1.625rem; font-weight: 600; color: var(--so-text); }
127
156
  .so-metric__suffix { font-size: 0.75rem; color: var(--so-text-muted); }
128
157
 
@@ -138,6 +167,7 @@
138
167
  .so-toolbar-freshness { font-size: 0.75rem; color: var(--so-text-subtle); }
139
168
 
140
169
  /* SO-067 Focus rings */
170
+ .so-btn:focus-visible,
141
171
  select:focus-visible,
142
172
  .so-btn--refresh:focus-visible,
143
173
  .so-toggle--pill input:focus-visible + .so-toggle__track,
@@ -239,17 +269,20 @@
239
269
  .so-table tr:hover td { background: var(--so-surface-muted); }
240
270
 
241
271
  .so-badge {
242
- display: inline-block;
243
- padding: 0.15rem 0.45rem;
244
- border-radius: var(--so-radius);
272
+ display: inline-flex;
273
+ align-items: center;
274
+ gap: 0.375rem;
275
+ border-radius: 9999px;
245
276
  font-size: 0.7rem;
246
- font-weight: 600;
277
+ line-height: 1rem;
278
+ font-weight: 500;
247
279
  }
248
- .so-badge--success { background: #dcfce7; color: #166534; }
249
- .so-badge--warning { background: #fef3c7; color: #92400e; }
250
- .so-badge--danger { background: #fee2e2; color: #991b1b; }
251
- .so-badge--info { background: #dbeafe; color: #1e40af; }
252
- .so-badge--default { background: var(--so-surface-muted); color: var(--so-text-muted); }
280
+ .so-badge--success { background: var(--so-badge-success-bg); color: var(--so-badge-success-text); }
281
+ .so-badge--warning { background: var(--so-badge-warning-bg); color: var(--so-badge-warning-text); }
282
+ .so-badge--danger { background: var(--so-badge-danger-bg); color: var(--so-badge-danger-text); }
283
+ .so-badge--info { background: var(--so-badge-info-bg); color: var(--so-badge-info-text); }
284
+ .so-badge--default { background: var(--so-surface-muted); color: var(--so-badge-neutral-text); }
285
+ .so-badge--recorded { background: var(--so-surface-muted); color: var(--so-text); }
253
286
 
254
287
  .so-badge--pill {
255
288
  display: inline-flex;
@@ -263,6 +296,15 @@
263
296
  .so-badge--pill.so-badge--success .so-badge__dot { fill: var(--so-success); }
264
297
  .so-badge--pill.so-badge--warning .so-badge__dot { fill: var(--so-warning); }
265
298
  .so-badge--pill.so-badge--danger .so-badge__dot { fill: var(--so-danger); }
299
+ .so-badge--pill.so-badge--info .so-badge__dot { fill: var(--so-info); }
300
+ .so-badge--pill.so-badge--default .so-badge__dot { fill: var(--so-badge-neutral-text); }
301
+ .so-badge--pill.so-badge--recorded .so-badge__dot { fill: var(--so-text); }
302
+
303
+ a.so-badge:focus-visible, button.so-badge:focus-visible {
304
+ outline: 2px solid var(--so-info);
305
+ outline-offset: 2px;
306
+ box-shadow: 0 0 0 3px var(--so-focus-ring);
307
+ }
266
308
 
267
309
  .so-stability {
268
310
  display: flex;
@@ -353,6 +395,73 @@
353
395
  .so-toggle__track, .so-toggle__thumb { transition: none; }
354
396
  }
355
397
 
398
+ .so-cache-controls { max-width: 820px; }
399
+ .so-cache-dashboard__intro,
400
+ .so-cache-controls__intro,
401
+ .so-queue-overview__intro {
402
+ display: flex;
403
+ flex-wrap: wrap;
404
+ align-items: center;
405
+ gap: 0.75rem;
406
+ margin-top: 0.5rem;
407
+ }
408
+ .so-cache-dashboard__hint,
409
+ .so-cache-controls__hint,
410
+ .so-queue-overview__hint {
411
+ font-size: 0.875rem;
412
+ color: var(--so-text-subtle);
413
+ line-height: 1.5;
414
+ }
415
+ .so-cache-dashboard__range-form {
416
+ align-items: center;
417
+ margin-bottom: 2rem;
418
+ }
419
+ .so-cache-dashboard__range-form select,
420
+ .so-dashboard-toolbar select {
421
+ padding: 0.4rem 0.6rem;
422
+ border: 1px solid var(--so-border);
423
+ border-radius: var(--so-radius);
424
+ font-size: 0.85rem;
425
+ background: var(--so-card-bg);
426
+ color: var(--so-text);
427
+ }
428
+ .so-cache-dashboard__chart-empty {
429
+ max-width: 420px;
430
+ margin-bottom: 0;
431
+ }
432
+ .so-cache-dashboard__digest {
433
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
434
+ white-space: nowrap;
435
+ }
436
+ .so-cache-control-row {
437
+ display: grid;
438
+ grid-template-columns: minmax(0, 1fr) auto;
439
+ gap: 1rem;
440
+ align-items: center;
441
+ padding: 1rem 0;
442
+ }
443
+ .so-cache-control-row + .so-cache-control-row { border-top: 1px solid var(--so-border); }
444
+ .so-cache-control-row__copy { min-width: 0; }
445
+ .so-cache-control-row__title {
446
+ font-size: 0.95rem;
447
+ font-weight: 600;
448
+ color: var(--so-text);
449
+ }
450
+ .so-cache-control-row__body {
451
+ margin-top: 0.35rem;
452
+ font-size: 0.875rem;
453
+ line-height: 1.5;
454
+ color: var(--so-text-subtle);
455
+ }
456
+ .so-cache-control-row__action {
457
+ display: flex;
458
+ justify-content: flex-end;
459
+ }
460
+ .so-cache-control-row__action .so-form--inline {
461
+ display: flex;
462
+ justify-content: flex-end;
463
+ }
464
+
356
465
  .so-empty { text-align: center; padding: 3rem 1rem; color: var(--so-text-muted); }
357
466
  .so-empty__icon { font-size: 2rem; margin-bottom: 0.5rem; }
358
467
  .so-empty__message { font-size: 0.9rem; }
@@ -425,6 +534,10 @@
425
534
  .so-sidebar__mode { display: inline-block; padding: 0.5rem 1rem; border-top: none; margin-top: 0; font-size: 0.7rem; }
426
535
  .so-content { padding: 1rem; }
427
536
  .so-stat-cards { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
537
+ .so-cache-control-row { grid-template-columns: 1fr; align-items: start; }
538
+ .so-cache-control-row__action,
539
+ .so-cache-control-row__action .so-form--inline { width: 100%; justify-content: stretch; }
540
+ .so-cache-control-row__action .so-btn { width: 100%; }
428
541
  .so-table { display: block; overflow-x: auto; }
429
542
  }
430
543
  </style>
@@ -433,12 +546,26 @@
433
546
  <div class="so-layout">
434
547
  <aside class="so-sidebar">
435
548
  <div class="so-sidebar__logo">SolidObserver</div>
436
- <nav class="so-sidebar__nav">
437
- <%= link_to "Dashboard", root_path, class: ("active" if controller_name == "dashboard") %>
438
- <%= link_to "Jobs", jobs_path, class: ("active" if controller_name == "jobs") %>
439
- <% if persistence_mode? %>
440
- <%= link_to "Events", events_path, class: ("active" if controller_name == "events") %>
441
- <%= link_to "Storage", storage_path, class: ("active" if controller_name == "storages") %>
549
+ <nav class="so-sidebar__nav" aria-label="SolidObserver navigation">
550
+ <% if SolidObserver.config.solid_queue_enabled? %>
551
+ <div class="so-sidebar__section">Queue</div>
552
+ <%= link_to "Overview", root_path, class: ("active" if controller_name == "dashboard" && @component.to_s != "cache"), "aria-current": (controller_name == "dashboard" && @component.to_s != "cache" ? "page" : nil) %>
553
+ <%= link_to "Jobs", jobs_path, class: ("active" if controller_name == "jobs"), "aria-current": (controller_name == "jobs" ? "page" : nil) %>
554
+ <% if persistence_mode? %>
555
+ <%= link_to "Events", events_path, class: ("active" if controller_name == "events"), "aria-current": (controller_name == "events" ? "page" : nil) %>
556
+ <% end %>
557
+ <% end %>
558
+
559
+ <% if SolidObserver.config.solid_cache_enabled? %>
560
+ <div class="so-sidebar__section">Cache</div>
561
+ <%= link_to "Overview", cache_dashboard_path, class: ("active" if controller_name == "cache_dashboard"), "aria-current": (controller_name == "cache_dashboard" ? "page" : nil) %>
562
+ <% if SolidObserver::Services::CacheOperations.available? %>
563
+ <%= link_to "Controls", cache_operations_path, class: ("active" if controller_name == "cache_operations"), "aria-current": (controller_name == "cache_operations" ? "page" : nil) %>
564
+ <% end %>
565
+ <% end %>
566
+
567
+ <% if persistence_mode? && (SolidObserver.config.solid_queue_enabled? || SolidObserver.config.solid_cache_enabled?) %>
568
+ <%= link_to "Storage", storage_path, class: ("active" if controller_name == "storages"), "aria-current": (controller_name == "storages" ? "page" : nil) %>
442
569
  <% end %>
443
570
  </nav>
444
571
  <div class="so-sidebar__mode">
@@ -449,10 +576,10 @@
449
576
  <main class="so-content" aria-labelledby="so-main-heading">
450
577
  <h1 class="sr-only" id="so-main-heading">Dashboard</h1>
451
578
  <% if flash[:notice] %>
452
- <div class="so-flash so-flash--notice"><%= flash[:notice] %></div>
579
+ <div class="so-flash so-flash--notice" role="status" aria-live="polite"><%= flash[:notice] %></div>
453
580
  <% end %>
454
581
  <% if flash[:alert] %>
455
- <div class="so-flash so-flash--alert"><%= flash[:alert] %></div>
582
+ <div class="so-flash so-flash--alert" role="alert"><%= flash[:alert] %></div>
456
583
  <% end %>
457
584
  <%= yield %>
458
585
  </main>
@@ -0,0 +1,40 @@
1
+ <section class="so-dashboard-section" aria-labelledby="so-cache-charts-heading">
2
+ <header class="so-dashboard-section__header">
3
+ <h2 id="so-cache-charts-heading" class="so-dashboard-section__title">Activity trends</h2>
4
+ </header>
5
+
6
+ <div class="so-chart-strip">
7
+ <% if @activity_trends&.[](:available) %>
8
+ <figure class="so-spark" data-so-spark="cache-hit-rate">
9
+ <figcaption class="so-spark__label">Hit rate <span data-so-range-copy><%= cache_range_label(@range) %></span></figcaption>
10
+ <svg class="so-spark__svg" viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true">
11
+ <line class="so-spark__baseline" x1="1" x2="119" y1="31" y2="31"/>
12
+ <polyline class="so-spark__line" points="<%= spark_points(@activity_trends[:hit_rate]) %>"/>
13
+ </svg>
14
+ <span class="so-spark__value"><%= cache_ratio_percent(@stats[:hit_rate]) %></span>
15
+ </figure>
16
+
17
+ <figure class="so-spark" data-so-spark="cache-operations">
18
+ <figcaption class="so-spark__label">Operations total <span data-so-range-copy><%= cache_range_label(@range) %></span></figcaption>
19
+ <svg class="so-spark__svg" viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true">
20
+ <line class="so-spark__baseline" x1="1" x2="119" y1="31" y2="31"/>
21
+ <polyline class="so-spark__line" points="<%= spark_points(@activity_trends[:operations]) %>"/>
22
+ </svg>
23
+ <span class="so-spark__value"><%= number_with_delimiter(@stats[:operations_count].to_i) %></span>
24
+ </figure>
25
+
26
+ <figure class="so-spark" data-so-spark="cache-errors">
27
+ <figcaption class="so-spark__label">Errors total <span data-so-range-copy><%= cache_range_label(@range) %></span></figcaption>
28
+ <svg class="so-spark__svg" viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true">
29
+ <line class="so-spark__baseline" x1="1" x2="119" y1="31" y2="31"/>
30
+ <polyline class="so-spark__line" points="<%= spark_points(@activity_trends[:errors]) %>"/>
31
+ </svg>
32
+ <span class="so-spark__value"><%= number_with_delimiter(@stats[:errors_count].to_i) %></span>
33
+ </figure>
34
+ <% else %>
35
+ <div class="so-card so-cache-dashboard__chart-empty">
36
+ <p class="so-dashboard-section__meta so-dashboard-section__meta--empty-range">No chart data in the selected range yet. Summary metrics still use bounded cache stats.</p>
37
+ </div>
38
+ <% end %>
39
+ </div>
40
+ </section>
@@ -0,0 +1,34 @@
1
+ <section class="so-card so-card--section" aria-labelledby="so-cache-events-heading">
2
+ <header class="so-dashboard-section__header">
3
+ <h2 id="so-cache-events-heading" class="so-dashboard-section__title">Sampled recent events</h2>
4
+ <span class="so-dashboard-section__meta">debug context only · no raw keys or values</span>
5
+ </header>
6
+
7
+ <% if recent_events.present? %>
8
+ <table class="so-table so-table--card">
9
+ <caption class="sr-only">Sampled cache events without raw cache keys or values</caption>
10
+ <thead>
11
+ <tr>
12
+ <th>Event</th>
13
+ <th>Key digest</th>
14
+ <th>Outcome</th>
15
+ <th>Duration</th>
16
+ <th>Recorded</th>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ <% recent_events.each do |event| %>
21
+ <tr>
22
+ <td><%= event.event_type.to_s.humanize %></td>
23
+ <td><span class="so-cache-dashboard__digest"><%= cache_event_digest(event.key_digest) %></span></td>
24
+ <td><%= cache_event_outcome_badge(event) %></td>
25
+ <td><%= event.duration ? format_duration(event.duration) : "—" %></td>
26
+ <td><span title="<%= event.recorded_at.utc.strftime("%Y-%m-%d %H:%M:%S UTC") %>"><%= time_ago_in_words(event.recorded_at) %> ago</span></td>
27
+ </tr>
28
+ <% end %>
29
+ </tbody>
30
+ </table>
31
+ <% else %>
32
+ <p class="so-dashboard-section__meta so-dashboard-section__meta--empty-events">No sampled cache events in the selected range yet. Slow, sampled, or errored cache operations will appear here.</p>
33
+ <% end %>
34
+ </section>
@@ -0,0 +1,39 @@
1
+ <% storage_summary = cache_storage_summary(storage_components) %>
2
+
3
+ <section class="so-dashboard-section" aria-labelledby="so-cache-summary-heading">
4
+ <header class="so-dashboard-section__header">
5
+ <h2 id="so-cache-summary-heading" class="so-dashboard-section__title">Summary in selected range</h2>
6
+ </header>
7
+
8
+ <div class="so-stat-cards so-cache-dashboard__summary">
9
+ <article class="so-card so-metric">
10
+ <div class="so-card__label">Hit rate</div>
11
+ <div class="so-metric__value"><%= cache_ratio_percent(stats[:hit_rate]) %></div>
12
+ <div class="so-card__subtitle">hits / read outcomes</div>
13
+ </article>
14
+
15
+ <article class="so-card so-metric">
16
+ <div class="so-card__label">Operations</div>
17
+ <div class="so-metric__value"><%= number_with_delimiter(stats[:operations_count].to_i) %></div>
18
+ <div class="so-card__subtitle">selected window</div>
19
+ </article>
20
+
21
+ <article class="<%= ["so-card", "so-metric", ("so-card--accent-danger" if stats[:errors_count].to_i.positive?)].compact.join(" ") %>">
22
+ <div class="so-card__label">Error rate</div>
23
+ <div class="so-metric__value"><%= cache_ratio_percent(stats[:error_rate]) %></div>
24
+ <div class="so-card__subtitle">errors / operations</div>
25
+ </article>
26
+
27
+ <article class="so-card so-metric">
28
+ <div class="so-card__label">Avg duration</div>
29
+ <div class="so-metric__value"><%= format_duration(stats[:avg_duration].to_f) %></div>
30
+ <div class="so-card__subtitle">operation latency</div>
31
+ </article>
32
+
33
+ <article class="so-card so-metric">
34
+ <div class="so-card__label">Storage footprint</div>
35
+ <div class="so-metric__value"><%= storage_summary[:value] %></div>
36
+ <div class="so-card__subtitle"><%= storage_summary[:subtitle] %></div>
37
+ </article>
38
+ </div>
39
+ </section>