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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +134 -81
- data/app/assets/stylesheets/solid_observer/application.css +18 -0
- data/app/controllers/solid_observer/cache_dashboard_controller.rb +59 -0
- data/app/controllers/solid_observer/cache_operations_controller.rb +24 -0
- data/app/controllers/solid_observer/dashboard_controller.rb +44 -1
- data/app/controllers/solid_observer/storages_controller.rb +1 -1
- data/app/helpers/solid_observer/application_helper.rb +154 -5
- data/app/helpers/solid_observer/dashboard_helper.rb +30 -11
- data/app/models/solid_observer/cache_event.rb +15 -0
- data/app/models/solid_observer/cache_metric.rb +14 -0
- data/app/models/solid_observer/storage_info.rb +4 -1
- data/app/views/layouts/solid_observer/application.html.erb +144 -17
- data/app/views/solid_observer/cache_dashboard/_charts.html.erb +40 -0
- data/app/views/solid_observer/cache_dashboard/_recent_events.html.erb +34 -0
- data/app/views/solid_observer/cache_dashboard/_summary.html.erb +39 -0
- data/app/views/solid_observer/cache_dashboard/index.html.erb +62 -0
- data/app/views/solid_observer/cache_operations/_confirm_clear.html.erb +6 -0
- data/app/views/solid_observer/cache_operations/index.html.erb +60 -0
- data/app/views/solid_observer/dashboard/index.html.erb +34 -4
- data/app/views/solid_observer/jobs/show.html.erb +3 -3
- data/app/views/solid_observer/storages/show.html.erb +64 -32
- data/bin/quality_gate +1 -1
- data/config/routes.rb +5 -0
- data/db/migrate/20260601000001_create_solid_observer_cache_events.rb +22 -0
- data/db/migrate/20260601000002_create_solid_observer_cache_metrics.rb +18 -0
- data/db/migrate/20260602000001_add_component_to_solid_observer_storage_infos.rb +8 -0
- data/lib/generators/solid_observer/templates/initializer.rb.tt +2 -1
- data/lib/solid_observer/cache_event_buffer.rb +53 -0
- data/lib/solid_observer/cache_subscriber.rb +47 -0
- data/lib/solid_observer/cli/storage.rb +16 -13
- data/lib/solid_observer/configuration.rb +22 -3
- data/lib/solid_observer/engine.rb +44 -7
- data/lib/solid_observer/services/cache_operations.rb +115 -0
- data/lib/solid_observer/services/cache_stats.rb +329 -0
- data/lib/solid_observer/services/cleanup_storage.rb +18 -2
- data/lib/solid_observer/services/database_size.rb +13 -8
- data/lib/solid_observer/services/flush_cache_event_buffer.rb +54 -0
- data/lib/solid_observer/services/record_cache_event.rb +142 -0
- data/lib/solid_observer/services/record_cache_metric.rb +74 -0
- data/lib/solid_observer/services/storage_info_snapshot.rb +128 -0
- data/lib/solid_observer/version.rb +1 -1
- data/lib/tasks/solid_observer.rake +29 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
series.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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-
|
|
243
|
-
|
|
244
|
-
|
|
272
|
+
display: inline-flex;
|
|
273
|
+
align-items: center;
|
|
274
|
+
gap: 0.375rem;
|
|
275
|
+
border-radius: 9999px;
|
|
245
276
|
font-size: 0.7rem;
|
|
246
|
-
|
|
277
|
+
line-height: 1rem;
|
|
278
|
+
font-weight: 500;
|
|
247
279
|
}
|
|
248
|
-
.so-badge--success { background:
|
|
249
|
-
.so-badge--warning { background:
|
|
250
|
-
.so-badge--danger { background:
|
|
251
|
-
.so-badge--info { background:
|
|
252
|
-
.so-badge--default { background: var(--so-surface-muted); color: var(--so-text
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
<%= link_to "
|
|
441
|
-
|
|
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>
|