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 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