clowk-phlex 0.1.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 +7 -0
- data/lib/clowk/phlex/charts/card.rb +518 -0
- data/lib/clowk/phlex/charts/chart_shape.rb +86 -0
- data/lib/clowk/phlex/charts/display_settings.rb +121 -0
- data/lib/clowk/phlex/charts/format.rb +48 -0
- data/lib/clowk/phlex/charts/gauge_linear.rb +143 -0
- data/lib/clowk/phlex/charts/gauge_radial.rb +82 -0
- data/lib/clowk/phlex/charts/interval_picker.rb +117 -0
- data/lib/clowk/phlex/charts/number_card.rb +365 -0
- data/lib/clowk/phlex/charts/range_picker.rb +176 -0
- data/lib/clowk/phlex/charts/sparkline.rb +261 -0
- data/lib/clowk/phlex/charts/time_series.rb +1058 -0
- data/lib/clowk/phlex/charts/type_picker.rb +109 -0
- data/lib/clowk/phlex/charts/web_time.rb +31 -0
- data/lib/clowk/phlex/component.rb +38 -0
- data/lib/clowk/phlex/ui/icons.rb +24 -0
- data/lib/clowk/phlex/ui/switch.rb +33 -0
- data/lib/clowk/phlex/version.rb +10 -0
- data/lib/clowk/phlex.rb +45 -0
- data/lib/clowk-phlex.rb +4 -0
- data/styles/clowk-phlex.css +156 -0
- metadata +141 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b10e1c1b36b5e16888028366d135b56fcacc162fd840dc1aaf5d2d5d7fbe5734
|
|
4
|
+
data.tar.gz: 6038808ec21e1151aeba34792e9208799c360f93c35db6f1cbb70c5f7f5ae3df
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7e8a286c705b5479c34fbce8551dc71a185ffc918bfcf35ebfd8ec93ce29e88329895ced4e620defbcd2a1e7a9799d723aaa2086f56e9ae14ae90a8aa2ed4ae8
|
|
7
|
+
data.tar.gz: 91501a9ff871de452a3a70135b783c73e350ab52638c6ce87c39196a438c7304f727c1fcdeaafc5b76ab71dce1ca737772cb149c471c97e3300e7aad8381cab4
|
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Components::Metrics::ChartCard — header (label + current value +
|
|
4
|
+
# min/avg/max strip + maximize button) + a Components::Metrics::Chart
|
|
5
|
+
# underneath. 2x2 grid layout on /metrics renders four of these per
|
|
6
|
+
# resource and per HTTP scope.
|
|
7
|
+
#
|
|
8
|
+
# Visual:
|
|
9
|
+
#
|
|
10
|
+
# ┌───────────────────────────────────────────────────────────┐
|
|
11
|
+
# │ CPU 25% min 21.9 avg 30.8 max 39.8 ⛶ │
|
|
12
|
+
# │ ┌───────────────────────────────────────────────────────┐ │
|
|
13
|
+
# │ │ chart … │ │
|
|
14
|
+
# │ └───────────────────────────────────────────────────────┘ │
|
|
15
|
+
# └───────────────────────────────────────────────────────────┘
|
|
16
|
+
#
|
|
17
|
+
# Maximize (⛶) opens a SHARED modal (see Components::Metrics::ChartModal)
|
|
18
|
+
# rendered once at the bottom of Views::Metrics::Index. The button
|
|
19
|
+
# is just an anchor with `data-turbo-stream="true"`; clicking it
|
|
20
|
+
# sends an Accept: text/vnd.turbo-stream.html GET to /metrics/chart,
|
|
21
|
+
# whose response targets the modal's slots
|
|
22
|
+
# (#chart-modal-title + #chart-modal-body) and invokes the custom
|
|
23
|
+
# chart_modal_open Turbo Stream action. No per-card overlay, no
|
|
24
|
+
# Stimulus controller for open/close — server drives the whole
|
|
25
|
+
# lifecycle via turbo_stream actions.
|
|
26
|
+
class Clowk::Phlex::Charts::Card < Clowk::Phlex::Component
|
|
27
|
+
# current — the unaggregated "right now" value from
|
|
28
|
+
# MetricsPageData (server-side latest field). When nil, falls
|
|
29
|
+
# back to series.last's bucket-aggregated value. The fallback
|
|
30
|
+
# only kicks in for cold-boot when the API hasn't shipped a
|
|
31
|
+
# latest yet; otherwise the headline tracks the literal latest
|
|
32
|
+
# sample and stays stable across range pills.
|
|
33
|
+
#
|
|
34
|
+
# expand_url: STRING → enables the maximize button. Caller
|
|
35
|
+
# (Views::Metrics::Index#render_chart_cards) builds the URL via
|
|
36
|
+
# metrics_chart_path with metric/source/scale baked in. Pass nil
|
|
37
|
+
# (or omit) to render a maximize-less card — used historically
|
|
38
|
+
# by call sites that don't have access to the full single-chart
|
|
39
|
+
# context; safe default.
|
|
40
|
+
# metric: STRING — the metric key (e.g. "cpu_percent"). When given,
|
|
41
|
+
# the root div gains data-metrics-display-target="card" +
|
|
42
|
+
# data-metric-key="<metric>" so MetricsDisplayController can hide/
|
|
43
|
+
# show this card based on the operator's display settings.
|
|
44
|
+
# Pass nil (or omit) to opt out of the display-filter system
|
|
45
|
+
# (e.g. standalone chart cards outside the main grid).
|
|
46
|
+
#
|
|
47
|
+
# section: STRING — "resource" or "http". When "http", a small
|
|
48
|
+
# inline [http] badge renders next to the metric label, giving
|
|
49
|
+
# operators a visual cue that the card is HTTP-derived. (The
|
|
50
|
+
# divider-style HTTP section header was removed in favor of this
|
|
51
|
+
# inline tag — fewer hard breaks in the grid, same signal.)
|
|
52
|
+
#
|
|
53
|
+
# default_visible: BOOLEAN — when false, the card emits
|
|
54
|
+
# data-default-visible="false". The metrics-display controller
|
|
55
|
+
# reads this on first connect for a kind that has no saved
|
|
56
|
+
# display settings yet and hides the card by default. Operator
|
|
57
|
+
# can un-hide it via the Settings drawer's Latency / Errors
|
|
58
|
+
# picker groups.
|
|
59
|
+
# capacity_label: STRING — "39 GB" / "512 MB" / etc. When given,
|
|
60
|
+
# the headline grows a "/ <capacity_label> · NN%" suffix so the
|
|
61
|
+
# card reads "21.9 GB / 39 GB · 56%" — mirrors the Overview's
|
|
62
|
+
# Memory/Disk cards. Pass nil for metrics with no natural total
|
|
63
|
+
# (CPU %, HTTP counts, network rates).
|
|
64
|
+
# capacity_pct: NUMBER — integer percentage paired with the label.
|
|
65
|
+
# Always renders alongside capacity_label; nil when the current
|
|
66
|
+
# sample is missing (we omit the "· NN%" trail in that case).
|
|
67
|
+
# chart_type: STRING — "area" (default, the time-series line+fill),
|
|
68
|
+
# "gauge_radial" (semicircle dial), or "gauge_linear" (capacity bar).
|
|
69
|
+
# Gauges need a ceiling (a percent metric, or a capacity_pct); when
|
|
70
|
+
# that's missing the card silently falls back to the area chart so a
|
|
71
|
+
# gauge panel on a limitless metric never renders blank.
|
|
72
|
+
# resizable: renders the column-resize grab handles (metrics-display#startResize).
|
|
73
|
+
# Only meaningful inside a metrics-display dashboard grid; pass false when the
|
|
74
|
+
# card lives in a plain grid (no drag-resize controller) so no dead affordance.
|
|
75
|
+
def initialize(label:, color:, unit:, range_ms:, points: [], current: nil, expand_url: nil, metric: nil, section: nil, default_visible: true, capacity_label: nil, capacity_pct: nil, chart_type: "area", percent: true, series: nil, resizable: true, gauge_bars: nil)
|
|
76
|
+
@resizable = resizable
|
|
77
|
+
# gauge_bars — MULTI linear gauge: [{label:, pct:, value_label:, capacity_label:, color:}]
|
|
78
|
+
# stacked one row per pod (chart_type "gauge_linear"). See render_body.
|
|
79
|
+
@gauge_bars = gauge_bars.is_a?(Array) ? gauge_bars : nil
|
|
80
|
+
@label = label
|
|
81
|
+
@color = color
|
|
82
|
+
@unit = unit
|
|
83
|
+
@points = Array(points)
|
|
84
|
+
# series — OPTIONAL multi-series (pilot: Line): one line per pod. When
|
|
85
|
+
# present the card renders a multi Chart + a legend instead of the single
|
|
86
|
+
# headline/stat strip. See Components::Metrics::Chart#series.
|
|
87
|
+
@series = series.is_a?(Array) ? series : nil
|
|
88
|
+
@range_ms = range_ms
|
|
89
|
+
@current = current
|
|
90
|
+
@expand_url = expand_url
|
|
91
|
+
@metric = metric
|
|
92
|
+
@section = section
|
|
93
|
+
@default_visible = default_visible
|
|
94
|
+
@capacity_label = capacity_label
|
|
95
|
+
@capacity_pct = capacity_pct
|
|
96
|
+
@chart_type = chart_type
|
|
97
|
+
# percent — false makes gauges read the raw value in the center instead of
|
|
98
|
+
# the fill "%" (a count has no natural ceiling → "% of peak" confuses).
|
|
99
|
+
@percent = percent
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def view_template
|
|
103
|
+
root_data = {}
|
|
104
|
+
|
|
105
|
+
if @metric
|
|
106
|
+
root_data[:metrics_display_target] = "card"
|
|
107
|
+
root_data[:metric_key] = @metric
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
root_data[:section] = @section if @section
|
|
111
|
+
root_data[:default_visible] = "false" unless @default_visible
|
|
112
|
+
|
|
113
|
+
div(
|
|
114
|
+
class: "relative bg-clowk-surface border border-clowk-border p-3.5 flex flex-col gap-2 min-w-0",
|
|
115
|
+
data: root_data
|
|
116
|
+
) do
|
|
117
|
+
card_header
|
|
118
|
+
render_body
|
|
119
|
+
stat_footer
|
|
120
|
+
if @metric && @resizable
|
|
121
|
+
resize_handle("left")
|
|
122
|
+
resize_handle("right")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# resize_handle — grab strip on the card's LEFT or RIGHT edge. Dragging it
|
|
130
|
+
# changes how many grid columns the card spans (metrics-display#startResize
|
|
131
|
+
# snaps to columns + persists); the left edge just inverts the drag direction.
|
|
132
|
+
# Only on cards with a metric key (the resizable ones); a no-op on mobile
|
|
133
|
+
# (single-column grid). The SVG chart reflows on the width change via the
|
|
134
|
+
# metrics-chart ResizeObserver.
|
|
135
|
+
def resize_handle(edge)
|
|
136
|
+
div(
|
|
137
|
+
data: {action: "pointerdown->metrics-display#startResize", resize_edge: edge},
|
|
138
|
+
aria: {hidden: "true"},
|
|
139
|
+
title: "Drag to resize",
|
|
140
|
+
class: tokens(
|
|
141
|
+
"absolute top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-clowk-accent/30 active:bg-clowk-accent/60 z-10 touch-none",
|
|
142
|
+
(edge == "left") ? "left-0" : "right-0"
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# render_body — the chart_type switch. Gauges fall back to the area
|
|
148
|
+
# chart when there's no usable ceiling (see gauge_pct).
|
|
149
|
+
# multi? — a multi-series (multi-pod) panel drawing one line per series.
|
|
150
|
+
def multi? = !@series.nil? && @series.any?
|
|
151
|
+
|
|
152
|
+
# gauge_multi? — a stacked multi linear gauge (one capacity bar per pod).
|
|
153
|
+
def gauge_multi? = !@gauge_bars.nil? && @gauge_bars.any? && @chart_type.to_s == "gauge_linear"
|
|
154
|
+
|
|
155
|
+
def render_body
|
|
156
|
+
if gauge_multi?
|
|
157
|
+
return div(class: "flex-1 flex flex-col justify-center") do
|
|
158
|
+
render Clowk::Phlex::Charts::GaugeLinear.new(bars: @gauge_bars)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
if multi?
|
|
163
|
+
return render Clowk::Phlex::Charts::TimeSeries.new(
|
|
164
|
+
points: [], series: @series,
|
|
165
|
+
color: @color, unit: @unit, label: @label,
|
|
166
|
+
# Line (raio) or Area (raio + translucent fill) — same multi machinery.
|
|
167
|
+
range_ms: @range_ms, height: 200, style: chart_style,
|
|
168
|
+
# @metric is the panel_key on a dashboard card — a stable id the chart
|
|
169
|
+
# keys its hidden-line state to so it survives realtime stream refreshes.
|
|
170
|
+
key: @metric
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Gauges are short; flex-1 lets the body grow to the card height (grid
|
|
175
|
+
# rows stretch to the tallest area chart) so the min/avg/max footer
|
|
176
|
+
# sits pinned at the bottom instead of floating mid-card.
|
|
177
|
+
if gauge?
|
|
178
|
+
div(class: "flex-1 flex flex-col justify-center") do
|
|
179
|
+
if gauge_radial?
|
|
180
|
+
render Clowk::Phlex::Charts::GaugeRadial.new(
|
|
181
|
+
pct: gauge_pct, color: @color, sub_label: gauge_sub_label,
|
|
182
|
+
percent: @percent, value_label: gauge_center_value
|
|
183
|
+
)
|
|
184
|
+
else
|
|
185
|
+
render Clowk::Phlex::Charts::GaugeLinear.new(
|
|
186
|
+
pct: gauge_pct, color: @color,
|
|
187
|
+
value_label: gauge_value_label, capacity_label: @capacity_label,
|
|
188
|
+
percent: @percent, center_value: gauge_center_value
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
else
|
|
193
|
+
render Clowk::Phlex::Charts::TimeSeries.new(
|
|
194
|
+
points: @points,
|
|
195
|
+
color: @color,
|
|
196
|
+
unit: @unit,
|
|
197
|
+
label: @label,
|
|
198
|
+
range_ms: @range_ms,
|
|
199
|
+
height: 200,
|
|
200
|
+
style: chart_style,
|
|
201
|
+
# Stable panel id so a single-series Line chart also keys its "Show
|
|
202
|
+
# dots" pref (options menu). Harmless on area/bars (no menu, no dots).
|
|
203
|
+
key: @metric
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# chart_style — Bar / Line / Area for the non-gauge branch.
|
|
209
|
+
def chart_style
|
|
210
|
+
case @chart_type.to_s
|
|
211
|
+
when "bars" then :bars
|
|
212
|
+
when "line" then :line
|
|
213
|
+
else :area
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def gauge? = gauge_radial? || gauge_linear?
|
|
218
|
+
def gauge_radial? = @chart_type.to_s == "gauge_radial" && !gauge_pct.nil?
|
|
219
|
+
def gauge_linear? = @chart_type.to_s == "gauge_linear" && !gauge_pct.nil?
|
|
220
|
+
|
|
221
|
+
# gauge_pct — the 0..100 fill. Capacity metrics (memory/disk) use the
|
|
222
|
+
# computed capacity_pct; percent metrics (CPU%) use the value itself.
|
|
223
|
+
# nil → no ceiling → ChartCard renders the area chart instead.
|
|
224
|
+
def gauge_pct
|
|
225
|
+
return @capacity_pct unless @capacity_pct.nil?
|
|
226
|
+
return @current || stats[:current] if percent_unit?
|
|
227
|
+
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# gauge_sub_label — radial center sub-line: "13.2 / 42 GB" for a
|
|
232
|
+
# capacity metric, nil for a percent metric (the % already is it).
|
|
233
|
+
def gauge_sub_label
|
|
234
|
+
return nil unless @capacity_label
|
|
235
|
+
|
|
236
|
+
v = @current || stats[:current]
|
|
237
|
+
return @capacity_label if v.nil?
|
|
238
|
+
|
|
239
|
+
"#{Clowk::Phlex::Charts::Format.number(v)} / #{@capacity_label}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# gauge_value_label — linear "used" figure ("13.2 GB"); nil for a
|
|
243
|
+
# percent metric.
|
|
244
|
+
def gauge_value_label
|
|
245
|
+
return nil if percent_unit? || @capacity_label.nil?
|
|
246
|
+
|
|
247
|
+
v = @current || stats[:current]
|
|
248
|
+
v.nil? ? nil : "#{Clowk::Phlex::Charts::Format.number(v)} #{@unit}".strip
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# gauge_center_value — the raw current value the radial shows in its center
|
|
252
|
+
# when percent: false (a count reads clearer than "% of peak").
|
|
253
|
+
def gauge_center_value
|
|
254
|
+
v = @current || stats[:current]
|
|
255
|
+
v.nil? ? nil : "#{Clowk::Phlex::Charts::Format.number(v)} #{@unit}".strip
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# card_header — colored label + big current value + right-aligned
|
|
259
|
+
# min/avg/max strip + maximize affordance.
|
|
260
|
+
#
|
|
261
|
+
# Headline current value preference order:
|
|
262
|
+
# 1. @current — set explicitly by the caller from the API's
|
|
263
|
+
# unaggregated `latest` field. Stable across
|
|
264
|
+
# range pills (the whole point).
|
|
265
|
+
# 2. series.last value — bucket-aggregated; only used when
|
|
266
|
+
# the API didn't ship a latest (cold boot or
|
|
267
|
+
# older controller).
|
|
268
|
+
#
|
|
269
|
+
# Named `card_header` (not `header`) because `header` is also
|
|
270
|
+
# a Phlex HTML tag — Phlex's method_missing for HTML tags
|
|
271
|
+
# collides with this method name.
|
|
272
|
+
def card_header
|
|
273
|
+
s = stats
|
|
274
|
+
|
|
275
|
+
# Header carries ONLY the identity + headline now: label + [http]
|
|
276
|
+
# badge + current value + capacity, with the maximize pinned
|
|
277
|
+
# top-right (shrink-0, outside the wrapping flow so it never orphans
|
|
278
|
+
# at narrow widths). The min/avg/max strip moved to stat_footer
|
|
279
|
+
# under the chart — mirrors the expand modal's layout, keeps the
|
|
280
|
+
# header clean and uncluttered on a 4-up grid.
|
|
281
|
+
# Header is single-line (no flex-wrap): a narrow card in a 4-up grid must
|
|
282
|
+
# NOT wrap the label/value/capacity onto a second line — that grows the card
|
|
283
|
+
# and pushes the whole grid row taller, breaking footer alignment across the
|
|
284
|
+
# row. Instead the label + capacity truncate (ellipsis) under pressure while
|
|
285
|
+
# the headline value (shrink-0) stays whole.
|
|
286
|
+
div(class: "flex items-start justify-between gap-2") do
|
|
287
|
+
div(class: "flex items-baseline gap-x-2.5 min-w-0 overflow-hidden") do
|
|
288
|
+
span(
|
|
289
|
+
class: "text-[11.5px] font-semibold uppercase tracking-[0.05em] min-w-0 truncate",
|
|
290
|
+
style: "color: #{@color};"
|
|
291
|
+
) { @label }
|
|
292
|
+
|
|
293
|
+
# [http] inline badge — replaces the old HTTP section divider.
|
|
294
|
+
# Same visual signal ("this metric comes from ingress logs") but
|
|
295
|
+
# without splitting the grid into two boxes.
|
|
296
|
+
if @section == "http"
|
|
297
|
+
span(
|
|
298
|
+
class: "text-[9.5px] font-clowk-mono text-clowk-muted-2 uppercase tracking-[0.06em] " \
|
|
299
|
+
"border border-clowk-border px-1 py-px translate-y-[-1px] shrink-0",
|
|
300
|
+
title: "HTTP metric (ingress)"
|
|
301
|
+
) { "http" }
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Render number + unit. For percent metrics the unit is part
|
|
305
|
+
# of the formatted string (so we can show "<0.01%" without
|
|
306
|
+
# the magnitude tier rendering "<0.01" with a separate "%"
|
|
307
|
+
# span looking like "<0.01 %"). For everything else the
|
|
308
|
+
# number stays plain and the unit hangs in its own muted
|
|
309
|
+
# span.
|
|
310
|
+
# Multi-series has no single headline value — show the pod count.
|
|
311
|
+
if multi?
|
|
312
|
+
span(class: "font-clowk-mono text-[12px] text-clowk-muted shrink-0 whitespace-nowrap") { "#{@series.size} pods" }
|
|
313
|
+
elsif gauge_multi?
|
|
314
|
+
span(class: "font-clowk-mono text-[12px] text-clowk-muted shrink-0 whitespace-nowrap") { "#{@gauge_bars.size} pods" }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Gauges render the value + capacity inside the dial/bar, so the
|
|
318
|
+
# header drops the big number to avoid showing it twice.
|
|
319
|
+
unless gauge? || multi? || gauge_multi?
|
|
320
|
+
span(class: "font-clowk-mono text-[22px] font-semibold text-clowk-text shrink-0 whitespace-nowrap") do
|
|
321
|
+
if percent_unit?
|
|
322
|
+
plain format_current(@current || s[:current])
|
|
323
|
+
else
|
|
324
|
+
plain format_current(@current || s[:current])
|
|
325
|
+
span(class: "text-clowk-muted text-[12px] font-normal ml-0.5") { @unit }
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
capacity_chip if @capacity_label
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Top-right actions: maximize (⛶) first, then the options menu (⋮) LAST —
|
|
334
|
+
# the triple-dot menu is conventionally the trailing icon.
|
|
335
|
+
div(class: "flex items-center gap-0.5 shrink-0") do
|
|
336
|
+
maximize_link if @expand_url
|
|
337
|
+
options_menu if options_menu?
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# options_menu? — which panels get the ⋮ options menu. Its sole option today
|
|
343
|
+
# ("Show dots") only makes sense where dots exist: Line (single + multi always
|
|
344
|
+
# draw dots) and multi Area (one dotted line per pod). Single Area has no dots,
|
|
345
|
+
# so it gets no menu.
|
|
346
|
+
def options_menu?
|
|
347
|
+
return true if @chart_type.to_s == "line"
|
|
348
|
+
|
|
349
|
+
multi? && @chart_type.to_s == "area"
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# options_menu — the triple-dot popover. The trigger lives in the header; the
|
|
353
|
+
# menu (portaled out on open by the popover controller to escape clipping)
|
|
354
|
+
# carries its own panel-options controller keyed by the panel id, so its
|
|
355
|
+
# toggle persists + broadcasts to the matching chart. Content must be self-
|
|
356
|
+
# contained (no data-action bound to an ancestor that won't survive portaling).
|
|
357
|
+
def options_menu
|
|
358
|
+
div(class: "relative", data: {controller: "popover"}) do
|
|
359
|
+
button(
|
|
360
|
+
type: "button",
|
|
361
|
+
data: {popover_target: "trigger", action: "popover#toggle"},
|
|
362
|
+
class: "inline-flex items-center justify-center w-7 h-7 text-clowk-muted hover:text-clowk-text hover:bg-clowk-surface-2",
|
|
363
|
+
aria: {label: "Panel options", haspopup: "true"}, title: "Panel options"
|
|
364
|
+
) { icon(:ellipsis_vertical, class: "w-4 h-4") }
|
|
365
|
+
|
|
366
|
+
div(
|
|
367
|
+
hidden: true,
|
|
368
|
+
data: {popover_target: "menu", controller: "panel-options", panel_options_key_value: @metric},
|
|
369
|
+
class: "min-w-[220px] bg-clowk-surface-2 border border-clowk-border shadow-xl overflow-hidden"
|
|
370
|
+
) do
|
|
371
|
+
# Header — names the panel, like a dropdown's section header.
|
|
372
|
+
div(class: "px-3 py-2 border-b border-clowk-border text-[10.5px] font-semibold uppercase tracking-[0.06em] text-clowk-muted-2 truncate") { @label }
|
|
373
|
+
|
|
374
|
+
# Option row — label + kbd hint (left), macOS-style switch (right).
|
|
375
|
+
label(class: "flex items-center justify-between gap-3 px-3 py-2.5 text-[12px] text-clowk-text-2 hover:bg-clowk-surface cursor-pointer select-none") do
|
|
376
|
+
span(class: "flex items-center gap-2 min-w-0") do
|
|
377
|
+
plain "Show dots"
|
|
378
|
+
option_kbd("D")
|
|
379
|
+
end
|
|
380
|
+
render Clowk::Phlex::UI::Switch.new(
|
|
381
|
+
checked: true,
|
|
382
|
+
data: {panel_options_target: "dots", action: "change->panel-options#toggleDots"}
|
|
383
|
+
)
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# option_kbd — the shortcut hint beside an option: pressing the key toggles it
|
|
390
|
+
# while the popover is open (see panel_options_controller).
|
|
391
|
+
def option_kbd(key)
|
|
392
|
+
span(class: "inline-flex items-center justify-center min-w-[16px] h-4 px-1 rounded border border-clowk-border bg-clowk-surface text-[10px] font-clowk-mono text-clowk-muted-2 leading-none") { key }
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# stat_footer — min/avg/max strip BELOW the chart, mirroring the
|
|
396
|
+
# expand modal's footer (Views::Metrics::ChartModalBody#stat_strip).
|
|
397
|
+
# Frees the header of the stats clutter so it stays clean even on a
|
|
398
|
+
# 4-up grid. Skipped when there's no data (the chart shows its own
|
|
399
|
+
# empty state).
|
|
400
|
+
def stat_footer
|
|
401
|
+
# Multi-series draws its own interactive legend inside the Chart (so the
|
|
402
|
+
# legend buttons sit in the metrics-chart controller scope + can toggle the
|
|
403
|
+
# lines). Nothing extra to render here.
|
|
404
|
+
return if multi? || gauge_multi?
|
|
405
|
+
return if @points.empty?
|
|
406
|
+
|
|
407
|
+
s = stats
|
|
408
|
+
|
|
409
|
+
# Single line, no wrap: a narrow card must keep min/avg/max on ONE row so
|
|
410
|
+
# the card height stays constant across the grid (a wrapped footer grows the
|
|
411
|
+
# card and misaligns the whole row). Block + nowrap + text-ellipsis clips
|
|
412
|
+
# with a "…" instead of breaking to a second line.
|
|
413
|
+
div(class: "min-w-0 overflow-hidden text-ellipsis whitespace-nowrap px-0.5") do
|
|
414
|
+
stat_chip("min", s[:min])
|
|
415
|
+
stat_chip("avg", s[:avg])
|
|
416
|
+
stat_chip("max", s[:max])
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# maximize_link — anchor with `data-turbo-stream="true"` so the
|
|
421
|
+
# GET request negotiates a turbo_stream response. The server
|
|
422
|
+
# (MetricsController#chart) renders a stream that updates the
|
|
423
|
+
# shared #chart-modal-* slots and fires the chart_modal_open
|
|
424
|
+
# action — all in one request, no client-side state to manage.
|
|
425
|
+
#
|
|
426
|
+
# Trade-off vs button + JS controller: cmd-click NOW opens
|
|
427
|
+
# /metrics/chart in a new tab as a normal page (format.html
|
|
428
|
+
# fallback). Previously this would just no-op or open a JS-only
|
|
429
|
+
# action. Honest hyperlink semantics restored for free.
|
|
430
|
+
def maximize_link
|
|
431
|
+
a(
|
|
432
|
+
href: @expand_url,
|
|
433
|
+
data: {turbo_stream: "true"},
|
|
434
|
+
title: "Expand chart",
|
|
435
|
+
"aria-label": "Expand #{@label} chart",
|
|
436
|
+
class: "inline-flex items-center justify-center w-7 h-7 text-clowk-muted hover:text-clowk-text hover:bg-clowk-surface-2 shrink-0"
|
|
437
|
+
) do
|
|
438
|
+
icon(:arrows_pointing_out, class: "w-3.5 h-3.5")
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# stat_chip — footer min/avg/max chip. Matches the expand modal's
|
|
443
|
+
# stat_strip vocabulary (muted label + emphasized value) so the
|
|
444
|
+
# inline card and the modal read the same.
|
|
445
|
+
# stat_chip — inline (NOT a flex item) so the parent's text-ellipsis can clip
|
|
446
|
+
# the row as one continuous line. mr-4 spaces the chips (last:mr-0 drops the
|
|
447
|
+
# trailing gap); inline-block would make each chip atomic and break the
|
|
448
|
+
# single-line ellipsis, so they stay plain inline spans.
|
|
449
|
+
def stat_chip(label, value)
|
|
450
|
+
span(class: "text-[11px] font-clowk-mono text-clowk-muted mr-4 last:mr-0") do
|
|
451
|
+
plain "#{label} "
|
|
452
|
+
span(class: "text-clowk-text font-semibold") { format_value(value) }
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# capacity_chip — the "of Y · NN%" suffix that pairs the headline
|
|
457
|
+
# current value with the resource's total. Renders just to the
|
|
458
|
+
# right of the headline so the operator reads "21.9 GB / 39 GB ·
|
|
459
|
+
# 56%" as one cohesive measurement. Muted styling keeps it from
|
|
460
|
+
# competing with the headline.
|
|
461
|
+
def capacity_chip
|
|
462
|
+
span(
|
|
463
|
+
class: "font-clowk-mono text-[12px] text-clowk-muted min-w-0 truncate",
|
|
464
|
+
title: @capacity_pct ? "current / total · #{@capacity_pct}% used" : "current / total"
|
|
465
|
+
) do
|
|
466
|
+
plain "/ #{@capacity_label}"
|
|
467
|
+
if @capacity_pct
|
|
468
|
+
plain " · "
|
|
469
|
+
span(class: "text-clowk-text-2") { "#{@capacity_pct}%" }
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# format_current — magnitude-adaptive headline. Percent metrics
|
|
475
|
+
# go through Clowk::Phlex::Charts::Format.percent so sub-1% values keep enough
|
|
476
|
+
# precision to be honest (0.05% instead of "0.0%"); other
|
|
477
|
+
# metrics use Clowk::Phlex::Charts::Format.number (the unit hangs in a separate
|
|
478
|
+
# muted span — see header).
|
|
479
|
+
def format_current(v)
|
|
480
|
+
return "—" if v.nil?
|
|
481
|
+
|
|
482
|
+
percent_unit? ? Clowk::Phlex::Charts::Format.percent(v) : Clowk::Phlex::Charts::Format.number(v)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# format_value — min/avg/max chips. Same logic as format_current
|
|
486
|
+
# so the headline + chips agree on precision (no more "current 0.0
|
|
487
|
+
# · avg 0.0 · max 0.0" lying about a chart that clearly varies).
|
|
488
|
+
def format_value(v)
|
|
489
|
+
return "—" if v.nil?
|
|
490
|
+
|
|
491
|
+
percent_unit? ? Clowk::Phlex::Charts::Format.percent(v) : Clowk::Phlex::Charts::Format.number(v)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# percent_unit? — whether the headline + chip formatters should
|
|
495
|
+
# bake the `%` into the formatted string. True only for actual
|
|
496
|
+
# percent units; "MB"/"GB"/"" stay number-only with the unit
|
|
497
|
+
# rendered in its own span.
|
|
498
|
+
def percent_unit?
|
|
499
|
+
@unit == "%"
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# stats — current/min/avg/max in one pass over the series. Same
|
|
503
|
+
# shape the inspiration computes in `stats()` (line 350-355).
|
|
504
|
+
def stats
|
|
505
|
+
return {min: nil, max: nil, avg: nil, current: nil} if @points.empty?
|
|
506
|
+
|
|
507
|
+
values = @points.map { |p| p[:value].to_f }
|
|
508
|
+
sum = values.sum
|
|
509
|
+
current = values.last
|
|
510
|
+
|
|
511
|
+
{
|
|
512
|
+
min: values.min,
|
|
513
|
+
max: values.max,
|
|
514
|
+
avg: sum / values.size,
|
|
515
|
+
current: current
|
|
516
|
+
}
|
|
517
|
+
end
|
|
518
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Components::Metrics::ChartShape — the little glyph for a chart TYPE
|
|
4
|
+
# (area / bars / line / radial / linear). ONE source of truth for the shape
|
|
5
|
+
# icons, shared by the dashboard builder's shape chips and the expand modal's
|
|
6
|
+
# chart-type dropdown (and whatever comes next).
|
|
7
|
+
#
|
|
8
|
+
# `css` sizes it: the default "w-full h-full" fills the big builder chip
|
|
9
|
+
# preview; a small "w-6 h-4" sits inline before a dropdown label.
|
|
10
|
+
#
|
|
11
|
+
# Everything draws with currentColor so the glyph inherits the surrounding
|
|
12
|
+
# text color — the builder sets it live to the metric hue, a dropdown row uses
|
|
13
|
+
# its own text/accent color.
|
|
14
|
+
class Clowk::Phlex::Charts::ChartShape < Clowk::Phlex::Component
|
|
15
|
+
# [{value, label, gauge}] for the time-series + gauge shapes, in display
|
|
16
|
+
# order. The canonical list the ChartTypePicker and the builder's metric
|
|
17
|
+
# shape chips both iterate. `gauge: true` ones need a metric with a ceiling.
|
|
18
|
+
METRIC_TYPES = [
|
|
19
|
+
{value: "area", label: "Area", gauge: false},
|
|
20
|
+
{value: "bars", label: "Bar", gauge: false},
|
|
21
|
+
{value: "line", label: "Line", gauge: false},
|
|
22
|
+
{value: "gauge_radial", label: "Radial", gauge: true},
|
|
23
|
+
{value: "gauge_linear", label: "Linear", gauge: true}
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
LABELS = METRIC_TYPES.to_h { |t| [t[:value], t[:label]] }.freeze
|
|
27
|
+
|
|
28
|
+
def initialize(type:, css: "w-full h-full")
|
|
29
|
+
@type = type.to_s
|
|
30
|
+
@css = css
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def view_template
|
|
34
|
+
case @type
|
|
35
|
+
when "bars" then bars
|
|
36
|
+
when "line" then line
|
|
37
|
+
when "gauge_radial" then radial
|
|
38
|
+
when "gauge_linear" then linear
|
|
39
|
+
else area
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def base_svg(fill: "none", &)
|
|
46
|
+
svg(viewBox: "0 0 80 40", class: @css, fill: fill, "aria-hidden": "true", preserveAspectRatio: "xMidYMid meet", &)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def area
|
|
50
|
+
base_svg do |s|
|
|
51
|
+
s.polygon(points: "0,28 20,18 40,22 60,10 80,16 80,40 0,40", fill: "currentColor", opacity: "0.18")
|
|
52
|
+
s.polyline(points: "0,28 20,18 40,22 60,10 80,16", stroke: "currentColor", "stroke-width": "2.5", "stroke-linejoin": "round", "stroke-linecap": "round")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def bars
|
|
57
|
+
base_svg do |s|
|
|
58
|
+
[[10, 22], [26, 12], [42, 18], [58, 8]].each do |x, y|
|
|
59
|
+
s.rect(x: x, y: y, width: "9", height: 36 - y, rx: "1.5", fill: "currentColor", opacity: "0.85")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def line
|
|
65
|
+
pts = [[8, 29], [30, 15], [52, 23], [72, 9]]
|
|
66
|
+
|
|
67
|
+
base_svg do |s|
|
|
68
|
+
s.polyline(points: pts.map { |x, y| "#{x},#{y}" }.join(" "), stroke: "currentColor", "stroke-width": "2.5", "stroke-linejoin": "round", "stroke-linecap": "round")
|
|
69
|
+
pts.each { |x, y| s.circle(cx: x, cy: y, r: "3", fill: "currentColor") }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def radial
|
|
74
|
+
base_svg do |s|
|
|
75
|
+
s.path(d: "M14 34 A26 26 0 0 1 66 34", stroke: "var(--clowk-border-2)", "stroke-width": "5", "stroke-linecap": "round")
|
|
76
|
+
s.path(d: "M14 34 A26 26 0 0 1 52 12", stroke: "currentColor", "stroke-width": "5", "stroke-linecap": "round")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def linear
|
|
81
|
+
base_svg(fill: nil) do |s|
|
|
82
|
+
s.rect(x: "8", y: "17", width: "64", height: "7", rx: "3.5", fill: "var(--clowk-border-2)")
|
|
83
|
+
s.rect(x: "8", y: "17", width: "42", height: "7", rx: "3.5", fill: "currentColor")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|