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
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Components::Metrics::NumberCard — a single big-number tile for a dashboard
|
|
4
|
+
# log-count panel (scope_kind "log"). Where ChartCard plots a warehouse
|
|
5
|
+
# series, this shows ONE number: how many log lines matched the panel's
|
|
6
|
+
# LogQuery filter over the dashboard's range (see LogMetricData).
|
|
7
|
+
#
|
|
8
|
+
# Visual — deliberately spare ("just the number"):
|
|
9
|
+
#
|
|
10
|
+
# ┌────────────────────────────┐
|
|
11
|
+
# │ FS · INVITE [1h] │ colored label + range chip
|
|
12
|
+
# │ │
|
|
13
|
+
# │ 1,284 │ big bold count, centered
|
|
14
|
+
# │ │
|
|
15
|
+
# └────────────────────────────┘
|
|
16
|
+
#
|
|
17
|
+
# Lives in the SAME metrics-display grid as the chart cards: it carries
|
|
18
|
+
# data-metric-key (the panel_key) so the Settings/Order drawer hides,
|
|
19
|
+
# reorders and resizes it alongside the charts. Grid stretch makes it match
|
|
20
|
+
# the height of any chart card sharing its row, so the number centers nicely.
|
|
21
|
+
class Clowk::Phlex::Charts::NumberCard < Clowk::Phlex::Component
|
|
22
|
+
# @param label [String] panel label ("fs · INVITE")
|
|
23
|
+
# @param color [String] accent color token for the label
|
|
24
|
+
# @param formatted [String] count with thousands separators ("1,284")
|
|
25
|
+
# @param range [String] dashboard range key, shown as a chip ("1h")
|
|
26
|
+
# @param metric [String, nil] the panel_key → data-metric-key for hide/reorder
|
|
27
|
+
# @param truncated [Boolean] count hit COUNT_CAP → render "≥ N"
|
|
28
|
+
# @param clamped [Boolean] range outran log retention → note "last Nd"
|
|
29
|
+
# @param series [Array] [{ts:, value:, formatted:}] for the trend sparkline.
|
|
30
|
+
# Empty (live-scan fallback before the warehouse fills) → no sparkline.
|
|
31
|
+
# @param range_ms [Integer] range width for the sparkline x-axis math.
|
|
32
|
+
# @param sub [String, nil] muted qualifier ("avg · duration_ms"); nil for a
|
|
33
|
+
# plain count.
|
|
34
|
+
# @param default_visible [Boolean]
|
|
35
|
+
def initialize(label:, color:, formatted:, range:, metric: nil,
|
|
36
|
+
truncated: false, clamped: false, series: [], numbers: nil, colored: true, range_ms: nil, sub: nil, default_visible: true, retention_days: 7, resizable: true)
|
|
37
|
+
@resizable = resizable
|
|
38
|
+
@retention_days = retention_days
|
|
39
|
+
@label = label
|
|
40
|
+
@color = color
|
|
41
|
+
@formatted = formatted
|
|
42
|
+
@range = range
|
|
43
|
+
@metric = metric
|
|
44
|
+
@truncated = truncated
|
|
45
|
+
@clamped = clamped
|
|
46
|
+
# series — SINGLE tile: a flat [{ts,value,formatted}] sparkline. MULTI tile
|
|
47
|
+
# (numbers present): the multi-series array [{label,color,points}] the shared
|
|
48
|
+
# timeline draws (one area per pod). numbers — MULTI only: one stat per pod
|
|
49
|
+
# ({label, color, formatted, value}) rendered side by side.
|
|
50
|
+
@series = Array(series)
|
|
51
|
+
@numbers = numbers
|
|
52
|
+
# colored — MULTI only: paint each stat in its pod's series color, or (false)
|
|
53
|
+
# render them all solid (text color) for a calmer wall. The timeline keeps its
|
|
54
|
+
# colors either way — the lines need them to stay apart.
|
|
55
|
+
@colored = colored
|
|
56
|
+
@range_ms = range_ms
|
|
57
|
+
@sub = sub
|
|
58
|
+
@default_visible = default_visible
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# multi? — a multi-pod Number: N current-value stats side by side over a shared
|
|
62
|
+
# multi-area timeline. @numbers carries the per-pod headlines.
|
|
63
|
+
def multi? = @numbers.is_a?(Array) && @numbers.size >= 2
|
|
64
|
+
|
|
65
|
+
# TV_VALUE_FONT / TV_CAPTION_FONT — the no-timeline stat sizes: a container
|
|
66
|
+
# query so each stat FILLS its column (readable on a wall-mounted TV). Applied
|
|
67
|
+
# server-side for a saved no-timeline panel AND by the number-card controller
|
|
68
|
+
# when the operator hides the timeline live via the popover (same rule: big
|
|
69
|
+
# ONLY while the timeline is hidden).
|
|
70
|
+
TV_VALUE_FONT = "clamp(28px, 20cqw, 120px)"
|
|
71
|
+
TV_CAPTION_FONT = "clamp(11px, 4cqw, 18px)"
|
|
72
|
+
|
|
73
|
+
def view_template
|
|
74
|
+
root_data = {}
|
|
75
|
+
|
|
76
|
+
if @metric
|
|
77
|
+
root_data[:metrics_display_target] = "card"
|
|
78
|
+
root_data[:metric_key] = @metric
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
root_data[:default_visible] = "false" unless @default_visible
|
|
82
|
+
|
|
83
|
+
# number-card controller → live show/hide of the sparkline from the options
|
|
84
|
+
# popover ("Show timeline" / "T"). Only wired when there IS a sparkline + a
|
|
85
|
+
# panel key to broadcast on; a bare number tile needs neither.
|
|
86
|
+
if @metric && chart?
|
|
87
|
+
root_data[:controller] = "number-card"
|
|
88
|
+
root_data[:number_card_key_value] = @metric
|
|
89
|
+
# Multi tile → hand the controller the no-timeline stat sizes so hiding the
|
|
90
|
+
# timeline live (popover) scales the stats up, same as a saved no-timeline
|
|
91
|
+
# panel; showing it restores the modest size.
|
|
92
|
+
if multi?
|
|
93
|
+
root_data[:number_card_stat_size_value] = TV_VALUE_FONT
|
|
94
|
+
root_data[:number_card_caption_size_value] = TV_CAPTION_FONT
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
div(
|
|
99
|
+
class: "relative bg-clowk-surface border border-clowk-border p-3.5 flex flex-col gap-2 min-w-0",
|
|
100
|
+
data: root_data
|
|
101
|
+
) do
|
|
102
|
+
card_header
|
|
103
|
+
value_block
|
|
104
|
+
timeline_block
|
|
105
|
+
|
|
106
|
+
if @metric && @resizable
|
|
107
|
+
resize_handle("left")
|
|
108
|
+
resize_handle("right")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
# Named card_header (not header) — `header` is a Phlex HTML tag method;
|
|
116
|
+
# method_missing would shadow the tag and break rendering.
|
|
117
|
+
def card_header
|
|
118
|
+
div(class: "flex flex-col gap-0.5 min-w-0") do
|
|
119
|
+
div(class: "flex items-start justify-between gap-2") do
|
|
120
|
+
span(
|
|
121
|
+
class: "text-[11.5px] font-semibold uppercase tracking-[0.05em] min-w-0 truncate",
|
|
122
|
+
style: "color: #{@color};"
|
|
123
|
+
) { @label }
|
|
124
|
+
|
|
125
|
+
div(class: "flex items-center gap-1 shrink-0") do
|
|
126
|
+
span(
|
|
127
|
+
class: "inline-flex items-center px-1.5 h-[18px] text-[10.5px] font-medium rounded-clowk-sm " \
|
|
128
|
+
"border border-clowk-border text-clowk-muted font-clowk-mono"
|
|
129
|
+
) { @range }
|
|
130
|
+
|
|
131
|
+
options_menu if @metric && chart?
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# sub — the agg + field qualifier ("avg · duration_ms"). Mono because the
|
|
136
|
+
# field is a JSON identifier; muted so it doesn't compete with the label.
|
|
137
|
+
span(class: "text-[10px] font-clowk-mono text-clowk-muted-2 truncate") { @sub } if @sub.present?
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# value_block — the headline number, centered + grown (flex-1) so it absorbs
|
|
142
|
+
# the card's extra height (vs the taller chart cards in the same row) and
|
|
143
|
+
# lands mid-tile above the sparkline — no empty gap.
|
|
144
|
+
def value_block
|
|
145
|
+
return multi_value_block if multi?
|
|
146
|
+
|
|
147
|
+
div(class: "flex-1 flex flex-col items-center justify-center gap-1.5 py-2") do
|
|
148
|
+
div(class: "flex items-baseline gap-1") do
|
|
149
|
+
span(class: "text-clowk-muted text-[22px] font-clowk-mono leading-none") { "≥" } if @truncated
|
|
150
|
+
span(class: "font-clowk-mono #{value_size_classes} font-semibold leading-none text-clowk-text") { @formatted }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
if @clamped
|
|
154
|
+
span(class: "text-[10.5px] text-clowk-muted-2 text-center px-2") do
|
|
155
|
+
"counted over last #{@retention_days}d (log retention)"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# multi_value_block — the multi-pod headline row: one CURRENT-value stat per pod
|
|
162
|
+
# side by side, each colored to match its timeline line, with the pod name as a
|
|
163
|
+
# caption below. The name truncates (+ tooltip) so a long "<server> · <pod>"
|
|
164
|
+
# never breaks the row; the stats share the width evenly (flex-1 min-w-0).
|
|
165
|
+
def multi_value_block
|
|
166
|
+
div(class: "flex-1 flex items-center justify-around gap-2 py-2 min-w-0") do
|
|
167
|
+
@numbers.each do |n|
|
|
168
|
+
# container-type: inline-size → the no-timeline headline sizes to THIS
|
|
169
|
+
# column's width (cqw), so it fills a wide wall-mounted TV card and
|
|
170
|
+
# auto-shrinks as pods multiply / the card narrows.
|
|
171
|
+
div(class: "flex flex-col items-center gap-1 min-w-0 flex-1", style: "container-type: inline-size;") do
|
|
172
|
+
span(
|
|
173
|
+
class: "font-clowk-mono #{multi_value_size} #{multi_value_color_class} font-semibold leading-none truncate max-w-full",
|
|
174
|
+
style: multi_value_style(n[:color]),
|
|
175
|
+
data: {number_card_target: "stat"}
|
|
176
|
+
) { n[:formatted] }
|
|
177
|
+
|
|
178
|
+
span(
|
|
179
|
+
class: "text-[10px] text-clowk-muted-2 truncate max-w-full",
|
|
180
|
+
style: multi_caption_style,
|
|
181
|
+
data: {tooltip: n[:label], number_card_target: "caption"}, "aria-label": n[:label]
|
|
182
|
+
) { n[:label] }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# multi_value_size — the per-pod headline font WITH a timeline: a fixed
|
|
189
|
+
# step-down by pod count (the chart owns the lower card, so the number stays
|
|
190
|
+
# modest — smaller on narrow, larger at vmd+). WITHOUT a timeline the size is
|
|
191
|
+
# container-relative instead (see multi_value_style), so this returns nothing.
|
|
192
|
+
def multi_value_size
|
|
193
|
+
return "" unless chart?
|
|
194
|
+
|
|
195
|
+
case @numbers.size
|
|
196
|
+
when 2 then "text-[34px] vmd:text-[44px]"
|
|
197
|
+
when 3 then "text-[26px] vmd:text-[36px]"
|
|
198
|
+
when 4 then "text-[20px] vmd:text-[28px]"
|
|
199
|
+
else "text-[18px] vmd:text-[24px]"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# multi_value_style — the headline's inline style: its series color, plus (when
|
|
204
|
+
# there's no timeline) a container-query font size so the stat FILLS its column.
|
|
205
|
+
# clamp keeps it readable on a narrow card and bounded on a huge TV; the middle
|
|
206
|
+
# cqw term is the sweet spot — ~gauge-sized on a normal card, bigger as the card
|
|
207
|
+
# widens. cqw resolves against the column (container-type set on the parent).
|
|
208
|
+
# WITH a timeline the size comes from multi_value_size instead, and the
|
|
209
|
+
# number-card controller applies TV_VALUE_FONT live if the operator hides it.
|
|
210
|
+
def multi_value_style(color)
|
|
211
|
+
base = @colored ? "color: #{color};" : ""
|
|
212
|
+
return base if chart?
|
|
213
|
+
|
|
214
|
+
"#{base} font-size: #{TV_VALUE_FONT};"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# multi_value_color_class — solid tiles paint every stat the text color (a
|
|
218
|
+
# class, so nothing competes with an inline color); colored tiles set the
|
|
219
|
+
# per-pod series color inline (multi_value_style).
|
|
220
|
+
def multi_value_color_class
|
|
221
|
+
@colored ? "" : "text-clowk-text"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# multi_caption_style — the caption keeps a base text-[10px] class; without a
|
|
225
|
+
# timeline it scales with its column too (container query), so it stays legible
|
|
226
|
+
# under the blown-up stat. (The controller applies the same live on popover-hide.)
|
|
227
|
+
def multi_caption_style
|
|
228
|
+
chart? ? "" : "font-size: #{TV_CAPTION_FONT};"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# chart? — whether this tile draws its timeline. SINGLE: ≥2 points. MULTI: any
|
|
232
|
+
# pod series carries points. The operator can also turn it off per-panel
|
|
233
|
+
# (show_chart false → empty series); then the tile is just the number(s).
|
|
234
|
+
def chart?
|
|
235
|
+
if multi?
|
|
236
|
+
@series.is_a?(Array) && @series.any? { |s| Array(s[:points]).any? }
|
|
237
|
+
else
|
|
238
|
+
@series.size >= 2
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# value_size_classes — the headline number's font size. A number-only tile
|
|
243
|
+
# (no chart) gives the count the entire card, so it scales up dramatically;
|
|
244
|
+
# a tile that ALSO draws a chart keeps a modest size (the chart needs the
|
|
245
|
+
# lower two-thirds). Long counts step down so a 7-figure number doesn't
|
|
246
|
+
# overflow the tile width (mono digits are wide). Sized off @formatted's
|
|
247
|
+
# length, which includes the thousands separators that take real space.
|
|
248
|
+
def value_size_classes
|
|
249
|
+
return "text-[40px] vmd:text-[44px]" if chart?
|
|
250
|
+
|
|
251
|
+
case @formatted.to_s.length
|
|
252
|
+
when 0..4 then "text-[72px] vmd:text-[88px]"
|
|
253
|
+
when 5..7 then "text-[52px] vmd:text-[60px]"
|
|
254
|
+
else "text-[40px] vmd:text-[44px]"
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# sparkline — the count's trend as a FULL area chart (axes + time X + value Y),
|
|
259
|
+
# the SAME area+gradient style the metric charts (CPU/Memory) use, so a count
|
|
260
|
+
# tile reads as "big number on top + a chart that matches its neighbours". The
|
|
261
|
+
# headline number (flex-1, centered) absorbs the slack, so the tile matches a
|
|
262
|
+
# neighbouring chart card's height with no gap.
|
|
263
|
+
#
|
|
264
|
+
# Height is 150 (vs the metric chart's 200): the big number + the "sum"
|
|
265
|
+
# sub-line eat the top of the tile, so a shorter chart keeps the count card's
|
|
266
|
+
# natural height ≤ the metric card's. That makes the metric card drive the
|
|
267
|
+
# row height and the number centers in the remaining space — instead of the
|
|
268
|
+
# count card being the tallest and forcing everything else taller. Needs ≥2
|
|
269
|
+
# points; the densified (full-window zero-fill) series keeps the area filled
|
|
270
|
+
# instead of collapsing to a bare line on mostly-zero counts.
|
|
271
|
+
# timeline_block — wraps the sparkline in the number-card controller's toggle
|
|
272
|
+
# target so the options popover ("Show timeline" / "T") can hide/reveal it live
|
|
273
|
+
# without a reload. Only present when there's a chart to toggle.
|
|
274
|
+
def timeline_block
|
|
275
|
+
return unless chart?
|
|
276
|
+
|
|
277
|
+
div(data: {number_card_target: "timeline"}) { sparkline }
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# sparkline — the timeline under the headline(s). SINGLE: a flat area sparkline
|
|
281
|
+
# of the one series. MULTI: the shared multi-AREA chart (one filled area per
|
|
282
|
+
# pod, reusing the multi-line/area Chart) — WITH its interactive legend, so the
|
|
283
|
+
# operator gets the same labels + click-to-hide/show + hover-highlight a real
|
|
284
|
+
# chart has. key: @metric persists the hidden-line selection across refreshes.
|
|
285
|
+
def sparkline
|
|
286
|
+
if multi?
|
|
287
|
+
render Clowk::Phlex::Charts::TimeSeries.new(
|
|
288
|
+
points: [], series: @series, color: @color, unit: "", label: @label,
|
|
289
|
+
range_ms: @range_ms || (60 * 60 * 1000), height: 150, axes: true,
|
|
290
|
+
style: :area, key: @metric
|
|
291
|
+
)
|
|
292
|
+
else
|
|
293
|
+
render Clowk::Phlex::Charts::TimeSeries.new(
|
|
294
|
+
points: @series, color: @color, unit: "", label: @label,
|
|
295
|
+
range_ms: @range_ms || (60 * 60 * 1000), height: 150, axes: true
|
|
296
|
+
)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# options_menu — the triple-dot popover (mirrors ChartCard's). Toggles:
|
|
301
|
+
# "Show timeline" (T) — the sparkline under the headline; and, on a MULTI tile,
|
|
302
|
+
# "Show dots" (D) — the per-pod markers on the multi-area timeline (a single
|
|
303
|
+
# area sparkline has no dots, so that tile skips it). The menu portals out on
|
|
304
|
+
# open, so it carries its own panel-options controller keyed by the panel id;
|
|
305
|
+
# the toggles persist (sessionStorage) + broadcast to this card's number-card
|
|
306
|
+
# controller (timeline) and the timeline chart's metrics-chart controller
|
|
307
|
+
# (dots), both matched by the same key.
|
|
308
|
+
def options_menu
|
|
309
|
+
div(class: "relative", data: {controller: "popover"}) do
|
|
310
|
+
button(
|
|
311
|
+
type: "button",
|
|
312
|
+
data: {popover_target: "trigger", action: "popover#toggle"},
|
|
313
|
+
class: "inline-flex items-center justify-center w-6 h-6 text-clowk-muted hover:text-clowk-text hover:bg-clowk-surface-2",
|
|
314
|
+
aria: {label: "Panel options", haspopup: "true"}, title: "Panel options"
|
|
315
|
+
) { icon(:ellipsis_vertical, class: "w-4 h-4") }
|
|
316
|
+
|
|
317
|
+
div(
|
|
318
|
+
hidden: true,
|
|
319
|
+
data: {popover_target: "menu", controller: "panel-options", panel_options_key_value: @metric},
|
|
320
|
+
class: "min-w-[220px] bg-clowk-surface-2 border border-clowk-border shadow-xl overflow-hidden"
|
|
321
|
+
) do
|
|
322
|
+
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 }
|
|
323
|
+
|
|
324
|
+
option_row(text: "Show timeline", kbd: "T", target: "timeline", action: "change->panel-options#toggleTimeline")
|
|
325
|
+
option_row(text: "Show dots", kbd: "D", target: "dots", action: "change->panel-options#toggleDots") if multi?
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# option_row — a popover toggle: label + kbd hint (left), macOS-style switch
|
|
331
|
+
# (right). checked by default; the panel-options controller reflects the stored
|
|
332
|
+
# pref on connect + fires the action.
|
|
333
|
+
def option_row(text:, kbd:, target:, action:)
|
|
334
|
+
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
|
|
335
|
+
span(class: "flex items-center gap-2 min-w-0") do
|
|
336
|
+
plain text
|
|
337
|
+
option_kbd(kbd)
|
|
338
|
+
end
|
|
339
|
+
render Clowk::Phlex::UI::Switch.new(
|
|
340
|
+
checked: true,
|
|
341
|
+
data: {panel_options_target: target, action: action}
|
|
342
|
+
)
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# option_kbd — the shortcut hint beside an option: pressing the key toggles it
|
|
347
|
+
# while the popover is open (see panel_options_controller).
|
|
348
|
+
def option_kbd(key)
|
|
349
|
+
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 }
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# resize_handle — grab strip on the card's edge; mirrors ChartCard so a
|
|
353
|
+
# number tile column-resizes like a chart in the metrics-display grid.
|
|
354
|
+
def resize_handle(edge)
|
|
355
|
+
div(
|
|
356
|
+
data: {action: "pointerdown->metrics-display#startResize", resize_edge: edge},
|
|
357
|
+
aria: {hidden: "true"},
|
|
358
|
+
title: "Drag to resize",
|
|
359
|
+
class: tokens(
|
|
360
|
+
"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",
|
|
361
|
+
(edge == "left") ? "left-0" : "right-0"
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Components::Metrics::RangePicker — time-range control for the metrics
|
|
4
|
+
# charts. The segmented preset pills (1m / 5m / 15m / 1h / 6h / 24h / 7d)
|
|
5
|
+
# stay visually identical to before; a trailing "Custom" chip opens a
|
|
6
|
+
# From/Until popover (mirrors the Logs Analytics filter) so an operator
|
|
7
|
+
# can pin an ABSOLUTE past window instead of a rolling "last N". Custom
|
|
8
|
+
# is the focus — the presets are shortcuts that seed the custom dates.
|
|
9
|
+
#
|
|
10
|
+
# Mechanics: ONE GET form driven by the shared `time-range-filter`
|
|
11
|
+
# Stimulus controller (preset↔custom highlight, local→UTC normalisation
|
|
12
|
+
# on submit). Presets are now buttons (they requestSubmit the form)
|
|
13
|
+
# instead of <a> links, but carry the same segmented chrome. The form
|
|
14
|
+
# targets `_top` with turbo_action advance: the whole page re-renders so
|
|
15
|
+
# BOTH the subtitle ("last 1h" → the custom window) and the chart frame
|
|
16
|
+
# reflect the new window — the same full-navigation outcome the old <a>
|
|
17
|
+
# pills gave, minus the hard reload.
|
|
18
|
+
class Clowk::Phlex::Charts::RangePicker < Clowk::Phlex::Component
|
|
19
|
+
RANGES = %w[1m 5m 15m 1h 6h 24h 7d].freeze
|
|
20
|
+
|
|
21
|
+
CHIP_ACTIVE = "border-clowk-accent-line bg-clowk-accent-dim text-clowk-accent-2"
|
|
22
|
+
CHIP_INACTIVE = "border-clowk-border bg-clowk-surface text-clowk-text-2 hover:bg-clowk-surface-2 hover:text-clowk-text"
|
|
23
|
+
|
|
24
|
+
# @param range [String] active preset key (ignored when custom)
|
|
25
|
+
# @param custom [Boolean] an explicit from/until window is in play
|
|
26
|
+
# @param from_iso/until_iso [String] resolved UTC window (custom round-trip)
|
|
27
|
+
# @param extra_params [Hash] query params carried on every submit
|
|
28
|
+
# (scope_kind/scope_id/pid/interval) — MUST exclude range/from/until.
|
|
29
|
+
# @param base_path [String] where the GET form submits. Defaults to
|
|
30
|
+
# metrics_path (the grid). The expand modal passes metrics_chart_path.
|
|
31
|
+
# @param turbo_stream [Boolean] modal mode: submit as a turbo-stream so the
|
|
32
|
+
# response swaps #chart-modal-body in place. Default false = the grid's
|
|
33
|
+
# full-page `_top` advance. Same knob as IntervalPicker — one range control
|
|
34
|
+
# serves both surfaces so the modal has the custom chip too.
|
|
35
|
+
def initialize(range:, custom: false, from_iso: nil, until_iso: nil, extra_params: {}, base_path: nil, turbo_stream: false)
|
|
36
|
+
@range = range.to_s
|
|
37
|
+
@custom = custom
|
|
38
|
+
@from_iso = from_iso
|
|
39
|
+
@until_iso = until_iso
|
|
40
|
+
@extra_params = extra_params
|
|
41
|
+
@base_path = base_path
|
|
42
|
+
@turbo_stream = turbo_stream
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def view_template
|
|
46
|
+
form(
|
|
47
|
+
method: "get",
|
|
48
|
+
action: @base_path,
|
|
49
|
+
data: {
|
|
50
|
+
controller: "time-range-filter",
|
|
51
|
+
time_range_filter_target: "form",
|
|
52
|
+
time_range_filter_range_value: active_range,
|
|
53
|
+
time_range_filter_from_value: @from_iso,
|
|
54
|
+
time_range_filter_until_value: @until_iso,
|
|
55
|
+
action: "submit->time-range-filter#normalizeDates",
|
|
56
|
+
# Modal submits as a turbo-stream (swaps the modal body in place);
|
|
57
|
+
# the grid advances the whole page in the top frame.
|
|
58
|
+
**(@turbo_stream ? {turbo_stream: "true"} : {turbo_frame: "_top", turbo_action: "advance"})
|
|
59
|
+
},
|
|
60
|
+
class: "flex items-center gap-2"
|
|
61
|
+
) do
|
|
62
|
+
input(type: "hidden", name: "range", value: active_range, data: {time_range_filter_target: "range"})
|
|
63
|
+
|
|
64
|
+
@extra_params.each do |name, value|
|
|
65
|
+
input(type: "hidden", name: name.to_s, value: value.to_s)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
preset_group
|
|
69
|
+
custom_chip
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def active_range
|
|
76
|
+
@custom ? "custom" : @range
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# preset_group — the segmented pills, chrome-identical to the old link
|
|
80
|
+
# group (joined border, mono, h-8). Buttons now so they submit the form
|
|
81
|
+
# via the controller's selectRange.
|
|
82
|
+
def preset_group
|
|
83
|
+
div(
|
|
84
|
+
role: "tablist",
|
|
85
|
+
aria: {label: "Time range"},
|
|
86
|
+
class: "inline-flex items-stretch border border-clowk-border bg-clowk-surface"
|
|
87
|
+
) do
|
|
88
|
+
RANGES.each_with_index do |r, i|
|
|
89
|
+
active = !@custom && r == @range
|
|
90
|
+
|
|
91
|
+
button(
|
|
92
|
+
type: "button",
|
|
93
|
+
role: "tab",
|
|
94
|
+
aria: {selected: active.to_s},
|
|
95
|
+
data: {
|
|
96
|
+
time_range_filter_target: "preset",
|
|
97
|
+
range: r,
|
|
98
|
+
action: "click->time-range-filter#selectRange"
|
|
99
|
+
},
|
|
100
|
+
class: tokens(
|
|
101
|
+
"inline-flex items-center justify-center min-w-9 px-2.5 h-8 font-clowk-mono text-[11px] font-bold",
|
|
102
|
+
i.positive? ? "border-l border-clowk-border" : nil,
|
|
103
|
+
active ? "bg-clowk-accent-dim text-clowk-accent-2" : "text-clowk-text-2 hover:bg-clowk-surface-2"
|
|
104
|
+
)
|
|
105
|
+
) { r }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# custom_chip — calendar button + popover (From/Until + Apply). Same
|
|
111
|
+
# markup the Logs Analytics filter uses, wired to the same controller.
|
|
112
|
+
# The label is JS-filled with the active window (local zone); "Custom"
|
|
113
|
+
# is just the pre-JS placeholder.
|
|
114
|
+
def custom_chip
|
|
115
|
+
div(class: "relative", data: {controller: "dropdown"}) do
|
|
116
|
+
button(
|
|
117
|
+
type: "button",
|
|
118
|
+
data: {
|
|
119
|
+
time_range_filter_target: "preset",
|
|
120
|
+
range: "custom",
|
|
121
|
+
action: "click->dropdown#toggle click->time-range-filter#openCustom"
|
|
122
|
+
},
|
|
123
|
+
class: tokens(
|
|
124
|
+
"inline-flex items-center gap-1.5 px-2.5 h-8 border text-[11px] font-medium transition-colors",
|
|
125
|
+
@custom ? CHIP_ACTIVE : CHIP_INACTIVE
|
|
126
|
+
)
|
|
127
|
+
) do
|
|
128
|
+
icon(:calendar_days, class: "w-3.5 h-3.5 shrink-0")
|
|
129
|
+
span(class: "hidden vmd:inline", data: {time_range_filter_target: "customLabel"}) { "Custom" }
|
|
130
|
+
icon(:chevron_down, class: "w-2.5 h-2.5 opacity-70")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
custom_popover
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def custom_popover
|
|
138
|
+
div(
|
|
139
|
+
hidden: true,
|
|
140
|
+
data: {dropdown_target: "menu"},
|
|
141
|
+
class: "absolute right-0 top-[calc(100%+4px)] z-40 w-[280px] max-w-[calc(100vw-24px)] " \
|
|
142
|
+
"border border-clowk-border-2 bg-clowk-surface shadow-2xl p-3 flex flex-col gap-3 text-left"
|
|
143
|
+
) do
|
|
144
|
+
span(class: "text-[10px] uppercase tracking-[0.06em] text-clowk-muted") { "Custom range" }
|
|
145
|
+
labeled_datetime("From", "from")
|
|
146
|
+
labeled_datetime("Until", "until")
|
|
147
|
+
button(
|
|
148
|
+
type: "button",
|
|
149
|
+
data: {action: "click->time-range-filter#applyCustom click->dropdown#close"},
|
|
150
|
+
class: "inline-flex items-center justify-center gap-1.5 px-3 h-8 border border-clowk-accent-line " \
|
|
151
|
+
"bg-clowk-accent-dim text-clowk-accent-2 text-[12px] font-medium hover:bg-clowk-accent/20 transition-colors"
|
|
152
|
+
) do
|
|
153
|
+
icon(:check, class: "w-3.5 h-3.5")
|
|
154
|
+
span { "Apply range" }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# labeled_datetime — visible datetime-local (display/edit only, no
|
|
160
|
+
# `name`) paired with a hidden companion carrying the UTC value. The
|
|
161
|
+
# controller fills the visible field from the resolved window in the
|
|
162
|
+
# browser's local zone and writes UTC into the hidden field on submit;
|
|
163
|
+
# assigning a "…Z" string to a datetime-local makes the browser blank it.
|
|
164
|
+
def labeled_datetime(label, field)
|
|
165
|
+
div(class: "flex flex-col gap-1 min-w-0") do
|
|
166
|
+
span(class: "text-[10px] uppercase tracking-wide text-clowk-muted") { label }
|
|
167
|
+
input(
|
|
168
|
+
type: "datetime-local",
|
|
169
|
+
data: {time_range_filter_target: "#{field}Input"},
|
|
170
|
+
class: "h-8 px-2 border border-clowk-border bg-clowk-surface text-[12px] text-clowk-text " \
|
|
171
|
+
"font-clowk-mono outline-none focus:border-clowk-accent-line"
|
|
172
|
+
)
|
|
173
|
+
input(type: "hidden", name: field, data: {time_range_filter_target: "#{field}Hidden"})
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|