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,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Views::Metrics::DisplaySettings — the "Settings" drawer body for the chart
|
|
4
|
+
# grid. A flat, domain-agnostic tile per card (portable → gem): click a tile to
|
|
5
|
+
# toggle its visibility, drag the grip to reorder, pick the column count. The
|
|
6
|
+
# "Update" button commits { hidden, order, cols } to the shared display store
|
|
7
|
+
# and fires `metrics-display:changed`; the grid re-applies live.
|
|
8
|
+
#
|
|
9
|
+
# `items` — [{ metric:, label:, color:, unit:, default_visible: }] for every
|
|
10
|
+
# manageable card on the page (those carrying a metric key).
|
|
11
|
+
class Clowk::Phlex::Charts::DisplaySettings < Clowk::Phlex::Component
|
|
12
|
+
def initialize(kind:, items:)
|
|
13
|
+
@kind = kind
|
|
14
|
+
@items = items
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def view_template
|
|
18
|
+
return if @items.empty?
|
|
19
|
+
|
|
20
|
+
div(
|
|
21
|
+
class: "p-4 flex flex-col gap-4 @container",
|
|
22
|
+
data: {
|
|
23
|
+
controller: "metrics-display-settings",
|
|
24
|
+
metrics_display_settings_kind_value: @kind
|
|
25
|
+
}
|
|
26
|
+
) do
|
|
27
|
+
header_row
|
|
28
|
+
hint_row
|
|
29
|
+
columns_picker
|
|
30
|
+
cards_grid
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def header_row
|
|
37
|
+
div(class: "flex items-center gap-2.5") do
|
|
38
|
+
span(class: "text-[10.5px] font-semibold uppercase tracking-[0.08em] font-clowk-mono text-clowk-muted shrink-0") { "Charts" }
|
|
39
|
+
span(class: "flex-1 h-px bg-clowk-border")
|
|
40
|
+
button(
|
|
41
|
+
type: "button",
|
|
42
|
+
data: {action: "click->metrics-display-settings#save", role: "update-btn"},
|
|
43
|
+
class: "inline-flex items-center gap-1.5 px-3 h-7 border border-clowk-border bg-clowk-surface " \
|
|
44
|
+
"text-clowk-text-2 text-[11.5px] font-medium hover:bg-clowk-surface-2 hover:text-clowk-text transition-colors shrink-0"
|
|
45
|
+
) { "Update" }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def hint_row
|
|
50
|
+
p(class: "text-[11px] text-clowk-muted-2 leading-relaxed") do
|
|
51
|
+
plain "Click to toggle visibility. Drag "
|
|
52
|
+
span(class: "inline-flex align-middle text-clowk-muted-2 mx-0.5") { icon(:bars_3, class: "w-3 h-3 inline") }
|
|
53
|
+
plain " to reorder. Press "
|
|
54
|
+
span(class: "font-semibold text-clowk-text-2") { "Update" }
|
|
55
|
+
plain " to apply."
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def columns_picker
|
|
60
|
+
div(class: "flex flex-wrap items-center gap-x-2.5 gap-y-1.5") do
|
|
61
|
+
span(class: "text-[10.5px] font-semibold uppercase tracking-[0.08em] font-clowk-mono text-clowk-muted shrink-0") { "Columns" }
|
|
62
|
+
span(class: "text-[10px] text-clowk-muted-2") { "(applies when 3+ visible)" }
|
|
63
|
+
div(class: "ml-auto inline-flex items-center gap-1") do
|
|
64
|
+
[1, 2, 3, 4].each { |n| cols_pill(n) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def cols_pill(n)
|
|
70
|
+
button(
|
|
71
|
+
type: "button",
|
|
72
|
+
data: {action: "click->metrics-display-settings#selectCols", cols: n.to_s, metrics_display_settings_target: "colsBtn"},
|
|
73
|
+
class: "inline-flex items-center justify-center w-8 h-7 border border-clowk-border bg-clowk-surface " \
|
|
74
|
+
"text-clowk-text-2 text-[11.5px] font-clowk-mono font-medium hover:bg-clowk-surface-2 hover:text-clowk-text transition-colors"
|
|
75
|
+
) { n.to_s }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def cards_grid
|
|
79
|
+
div(
|
|
80
|
+
data: {metrics_display_settings_target: "grid"},
|
|
81
|
+
class: "grid grid-cols-2 @sm:grid-cols-3 gap-2"
|
|
82
|
+
) do
|
|
83
|
+
@items.each { |item| card_tile(item) }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def card_tile(spec)
|
|
88
|
+
div(
|
|
89
|
+
data: {
|
|
90
|
+
metrics_display_settings_target: "card",
|
|
91
|
+
metric: spec[:metric],
|
|
92
|
+
default_visible: (spec[:default_visible] == false) ? "false" : "true",
|
|
93
|
+
action: "click->metrics-display-settings#toggle"
|
|
94
|
+
},
|
|
95
|
+
class: "relative flex flex-col gap-1.5 p-2.5 cursor-pointer select-none border border-clowk-border " \
|
|
96
|
+
"bg-clowk-surface-2 hover:bg-clowk-surface transition-colors"
|
|
97
|
+
) do
|
|
98
|
+
div(class: "flex items-center gap-1.5") do
|
|
99
|
+
span(
|
|
100
|
+
data: {role: "drag-handle"},
|
|
101
|
+
class: "cursor-grab active:cursor-grabbing text-clowk-muted-2 hover:text-clowk-text shrink-0",
|
|
102
|
+
title: "Drag to reorder"
|
|
103
|
+
) { icon(:bars_3, class: "w-3 h-3 pointer-events-none") }
|
|
104
|
+
|
|
105
|
+
span(class: "inline-block w-2 h-2 rounded-full shrink-0", style: "background: #{spec[:color]};")
|
|
106
|
+
|
|
107
|
+
span(data: {role: "check"}, class: "ml-auto text-clowk-accent-2") do
|
|
108
|
+
icon(:check, class: "w-3 h-3")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
span(class: "text-[11px] font-semibold font-clowk-mono text-clowk-text truncate leading-tight") { spec[:label] }
|
|
113
|
+
|
|
114
|
+
if spec[:unit].present?
|
|
115
|
+
span(class: "text-[10px] font-clowk-mono text-clowk-muted-2 leading-tight") { spec[:unit] }
|
|
116
|
+
else
|
|
117
|
+
span(class: "text-[10px] leading-tight invisible") { "·" }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clowk
|
|
4
|
+
module Phlex
|
|
5
|
+
module Charts
|
|
6
|
+
# Format — magnitude-adaptive decimal formatting for chart labels, headline
|
|
7
|
+
# values and tooltips (ex-MetricFormat). A flat "%.1f" floors sub-0.1 values
|
|
8
|
+
# to "0.0", which makes an idle series read as zero even when the curve
|
|
9
|
+
# clearly moves — the shape is right but every number lies. These tiers keep
|
|
10
|
+
# enough precision for the magnitude you're looking at.
|
|
11
|
+
#
|
|
12
|
+
# >= 100 → 0 decimals ("142")
|
|
13
|
+
# >= 10 → 1 decimal ("42.5")
|
|
14
|
+
# >= 1 → 1 decimal ("4.2")
|
|
15
|
+
# >= 0.01 → 2 decimals ("0.05")
|
|
16
|
+
# > 0 → "<0.01"
|
|
17
|
+
# = 0 → "0"
|
|
18
|
+
module Format
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def number(v)
|
|
22
|
+
return "—" if v.nil?
|
|
23
|
+
return "0" if v.zero?
|
|
24
|
+
|
|
25
|
+
abs = v.abs
|
|
26
|
+
return v.round.to_s if abs >= 100
|
|
27
|
+
return v.round(1).to_s if abs >= 10
|
|
28
|
+
return v.round(1).to_s if abs >= 1
|
|
29
|
+
return v.round(2).to_s if abs >= 0.01
|
|
30
|
+
|
|
31
|
+
"<0.01"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def percent(v)
|
|
35
|
+
return "—" if v.nil?
|
|
36
|
+
return "0%" if v.zero?
|
|
37
|
+
|
|
38
|
+
abs = v.abs
|
|
39
|
+
return "#{v.round}%" if abs >= 100
|
|
40
|
+
return format("%.1f%%", v) if abs >= 1
|
|
41
|
+
return format("%.2f%%", v) if abs >= 0.01
|
|
42
|
+
|
|
43
|
+
"<0.01%"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Components::UI::GaugeLinear — horizontal capacity bar for a capacity / percent
|
|
4
|
+
# metric (ported from voodu Metrics::GaugeLinear): a big % up top, a filled
|
|
5
|
+
# track, and used / total figures underneath. The fill tints AMBER past 70% and
|
|
6
|
+
# RED past 90% (same thresholds as GaugeRadial).
|
|
7
|
+
#
|
|
8
|
+
# percent: true → headline reads the fill "%".
|
|
9
|
+
# percent: false → headline reads center_value (raw count) instead.
|
|
10
|
+
#
|
|
11
|
+
# MULTI mode — pass `bars: [{label:, pct:, value_label:, capacity_label:, color:}]`
|
|
12
|
+
# to stack one labeled capacity bar per row (e.g. one per pod). Each row shows
|
|
13
|
+
# label + value on top, a fill bar, and (when `capacity_label` is given) a
|
|
14
|
+
# pct / total line underneath. Multi options:
|
|
15
|
+
#
|
|
16
|
+
# dot: true → a colored square before each label (breakdown style)
|
|
17
|
+
# threshold: false → keep each bar its own color (no amber>70 / red>90 tint)
|
|
18
|
+
# title: "…" → a section header row (uppercase label)
|
|
19
|
+
# total_label: "…" → a "Total <x>" figure shown at the right of the header
|
|
20
|
+
#
|
|
21
|
+
# The defaults (dot: false, threshold: true) give the capacity-gauge look; a
|
|
22
|
+
# simple cost/usage breakdown is `dot: true, threshold: false` with a title +
|
|
23
|
+
# total and value_label-only rows (no capacity_label → no bottom line).
|
|
24
|
+
class Clowk::Phlex::Charts::GaugeLinear < Clowk::Phlex::Component
|
|
25
|
+
def initialize(pct: nil, color: "#5B8DEF", value_label: nil, capacity_label: nil, percent: true, center_value: nil,
|
|
26
|
+
bars: nil, dot: false, threshold: true, title: nil, total_label: nil)
|
|
27
|
+
@pct = clamp(pct.to_f) if pct
|
|
28
|
+
@color = color
|
|
29
|
+
@value_label = value_label
|
|
30
|
+
@capacity_label = capacity_label
|
|
31
|
+
@percent = percent
|
|
32
|
+
@center_value = center_value
|
|
33
|
+
@bars = bars.is_a?(Array) ? bars : nil
|
|
34
|
+
@dot = dot
|
|
35
|
+
@threshold = threshold
|
|
36
|
+
@title = title
|
|
37
|
+
@total_label = total_label
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def multi? = !@bars.nil? && @bars.any?
|
|
41
|
+
|
|
42
|
+
def view_template
|
|
43
|
+
return multi_template if multi?
|
|
44
|
+
|
|
45
|
+
div(class: "flex flex-col justify-center gap-3 py-4 min-h-[120px]") do
|
|
46
|
+
span(class: "font-clowk-mono text-[26px] font-semibold text-clowk-text leading-none") { headline }
|
|
47
|
+
|
|
48
|
+
div(class: "h-3.5 w-full bg-clowk-surface-3 overflow-hidden rounded-clowk-sm") do
|
|
49
|
+
div(style: "width: #{@pct.round(1)}%; height: 100%; background: #{fill_color};")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if @value_label.present? || @capacity_label.present?
|
|
53
|
+
div(class: "flex items-center justify-between font-clowk-mono text-[11px] text-clowk-muted") do
|
|
54
|
+
span { @value_label.to_s.presence || "—" }
|
|
55
|
+
span { @capacity_label.to_s.presence || "" }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# multi_template — an optional header (title + total) over N stacked bars.
|
|
64
|
+
def multi_template
|
|
65
|
+
div(class: "flex flex-col gap-3 #{"justify-center py-3 min-h-[120px]" unless @title}") do
|
|
66
|
+
multi_header if @title
|
|
67
|
+
@bars.each { |b| gauge_row(b) }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def multi_header
|
|
72
|
+
div(class: "flex items-center justify-between") do
|
|
73
|
+
span(class: "text-[11px] font-semibold uppercase tracking-[0.05em] text-clowk-muted") { @title }
|
|
74
|
+
|
|
75
|
+
if @total_label
|
|
76
|
+
span(class: "text-[12px] text-clowk-muted") do
|
|
77
|
+
plain "Total "
|
|
78
|
+
span(class: "font-clowk-mono text-clowk-amber") { @total_label }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def gauge_row(bar)
|
|
85
|
+
pct = clamp(bar[:pct].to_f)
|
|
86
|
+
base = bar[:color] || @color
|
|
87
|
+
fill = @threshold ? row_fill_color(pct, base) : base
|
|
88
|
+
|
|
89
|
+
div(class: "flex flex-col gap-1.5") do
|
|
90
|
+
div(class: "flex items-baseline justify-between gap-2") do
|
|
91
|
+
span(class: "flex items-center gap-2 min-w-0 font-clowk-mono text-[11.5px] text-clowk-text-2 truncate") do
|
|
92
|
+
span(class: "w-2 h-2 rounded-[2px] shrink-0", style: "background: #{base};") if @dot
|
|
93
|
+
span(class: "truncate") { bar[:label].to_s }
|
|
94
|
+
end
|
|
95
|
+
span(class: "font-clowk-mono text-[11.5px] font-semibold text-clowk-text shrink-0") { bar[:value_label].to_s.presence || pct_string(pct) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
div(class: "h-2.5 w-full bg-clowk-surface-3 overflow-hidden rounded-full") do
|
|
99
|
+
div(class: "h-full rounded-full", style: "width: #{pct.round(1)}%; background: #{fill};")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if bar[:capacity_label].present?
|
|
103
|
+
div(class: "flex items-center justify-between font-clowk-mono text-[10px] text-clowk-muted-2") do
|
|
104
|
+
span { pct_string(pct) }
|
|
105
|
+
span { bar[:capacity_label].to_s }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def row_fill_color(pct, color)
|
|
112
|
+
return "var(--clowk-red)" if pct >= 90
|
|
113
|
+
return "var(--clowk-amber)" if pct >= 70
|
|
114
|
+
|
|
115
|
+
color
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def pct_string(pct)
|
|
119
|
+
(pct < 10) ? "#{"%.1f" % pct}%" : "#{pct.round}%"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def fill_color
|
|
123
|
+
return "var(--clowk-red)" if @pct >= 90
|
|
124
|
+
return "var(--clowk-amber)" if @pct >= 70
|
|
125
|
+
|
|
126
|
+
@color
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def headline
|
|
130
|
+
(!@percent && @center_value.to_s.present?) ? @center_value.to_s : pct_label
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def pct_label
|
|
134
|
+
(@pct < 10) ? "#{"%.1f" % @pct}%" : "#{@pct.round}%"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def clamp(v)
|
|
138
|
+
return 0.0 if v.negative?
|
|
139
|
+
return 100.0 if v > 100
|
|
140
|
+
|
|
141
|
+
v
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Components::UI::GaugeRadial — semicircle "fuel gauge" for a capacity / percent
|
|
4
|
+
# metric (ported from voodu Metrics::GaugeRadial). The arc fills with the
|
|
5
|
+
# current %, the number sits big in the center and an absolute value (e.g.
|
|
6
|
+
# "13.2 / 42 GB") below it. The fill tints AMBER past 70% and RED past 90% so a
|
|
7
|
+
# resource nearing its ceiling jumps out. Pure server SVG (no JS).
|
|
8
|
+
#
|
|
9
|
+
# percent: true → center reads the fill "%".
|
|
10
|
+
# percent: false → center reads value_label (raw count) instead.
|
|
11
|
+
class Clowk::Phlex::Charts::GaugeRadial < Clowk::Phlex::Component
|
|
12
|
+
R = 76 # arc radius
|
|
13
|
+
CX = 100 # center x
|
|
14
|
+
CY = 96 # baseline y the arc springs up from
|
|
15
|
+
SW = 16 # arc stroke width
|
|
16
|
+
|
|
17
|
+
def initialize(pct:, color: "#5B8DEF", sub_label: nil, max_w: 220, percent: true, value_label: nil)
|
|
18
|
+
@pct = clamp(pct.to_f)
|
|
19
|
+
@color = color
|
|
20
|
+
@sub_label = sub_label
|
|
21
|
+
@max_w = max_w
|
|
22
|
+
@percent = percent
|
|
23
|
+
@value_label = value_label.to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def view_template
|
|
27
|
+
arc = Math::PI * R
|
|
28
|
+
fill = (@pct / 100.0) * arc
|
|
29
|
+
|
|
30
|
+
svg(
|
|
31
|
+
viewBox: "0 0 200 116",
|
|
32
|
+
class: "block mx-auto w-full h-auto", style: "max-width: #{@max_w}px;", fill: "none",
|
|
33
|
+
role: "img", "aria-label": "#{@pct.round}%"
|
|
34
|
+
) do |s|
|
|
35
|
+
s.path(d: arc_path, stroke: "var(--clowk-surface-3)", "stroke-width": SW, "stroke-linecap": "round")
|
|
36
|
+
s.path(
|
|
37
|
+
d: arc_path, stroke: fill_color,
|
|
38
|
+
"stroke-width": SW, "stroke-linecap": "round",
|
|
39
|
+
"stroke-dasharray": "#{fill.round(2)} #{arc.round(2)}"
|
|
40
|
+
)
|
|
41
|
+
s.text(
|
|
42
|
+
x: CX, y: CY - 6, "text-anchor": "middle",
|
|
43
|
+
fill: "var(--clowk-text)", "font-size": "30", "font-weight": "600",
|
|
44
|
+
"font-family": "var(--clowk-font-sans, system-ui, sans-serif)"
|
|
45
|
+
) { center_label }
|
|
46
|
+
|
|
47
|
+
if @sub_label.present?
|
|
48
|
+
s.text(
|
|
49
|
+
x: CX, y: CY + 13, "text-anchor": "middle",
|
|
50
|
+
fill: "var(--clowk-muted)", "font-size": "12",
|
|
51
|
+
"font-family": "var(--clowk-font-mono, ui-monospace, monospace)"
|
|
52
|
+
) { @sub_label }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def arc_path = "M #{CX - R} #{CY} A #{R} #{R} 0 0 1 #{CX + R} #{CY}"
|
|
60
|
+
|
|
61
|
+
def fill_color
|
|
62
|
+
return "var(--clowk-red)" if @pct >= 90
|
|
63
|
+
return "var(--clowk-amber)" if @pct >= 70
|
|
64
|
+
|
|
65
|
+
@color
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def center_label
|
|
69
|
+
(!@percent && @value_label.present?) ? @value_label : pct_label
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def pct_label
|
|
73
|
+
(@pct < 10) ? "#{"%.1f" % @pct}%" : "#{@pct.round}%"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def clamp(v)
|
|
77
|
+
return 0.0 if v.negative?
|
|
78
|
+
return 100.0 if v > 100
|
|
79
|
+
|
|
80
|
+
v
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Components::Metrics::IntervalPicker — dropdown that controls the
|
|
4
|
+
# chart's bucket size (the x-axis density) INDEPENDENTLY from the
|
|
5
|
+
# range pills. Range answers "how far back?"; interval answers
|
|
6
|
+
# "how granular?".
|
|
7
|
+
#
|
|
8
|
+
# Why a separate control instead of preset range/interval pairs:
|
|
9
|
+
# operator flexibility. The same "last 1h" can be examined at 1m
|
|
10
|
+
# (60 spikes) or 5m (12 smoothed points) depending on what they're
|
|
11
|
+
# hunting — a burst hidden in the average vs the overall trend.
|
|
12
|
+
#
|
|
13
|
+
# Mirrors the DS dropdown pattern of Components::Metrics::MetricPicker
|
|
14
|
+
# (same `data-controller="dropdown"`, same trigger/menu structure)
|
|
15
|
+
# so the modal toolbar reads as a row of consistent pickers.
|
|
16
|
+
#
|
|
17
|
+
# Mounted on:
|
|
18
|
+
# - Views::Metrics::Index (page toolbar, next to the RangePicker)
|
|
19
|
+
# - Views::Metrics::ChartModalBody (modal toolbar, next to the
|
|
20
|
+
# range pills + metric picker + pod picker)
|
|
21
|
+
#
|
|
22
|
+
# URL contract: the active interval lives in the `interval` query
|
|
23
|
+
# param. `auto` is the default and is OMITTED from the URL (clean
|
|
24
|
+
# URLs by default — operator only sees the param when they've
|
|
25
|
+
# explicitly picked one).
|
|
26
|
+
class Clowk::Phlex::Charts::IntervalPicker < Clowk::Phlex::Component
|
|
27
|
+
# Drop OPTIONS in lockstep with MetricsPageData::INTERVALS and
|
|
28
|
+
# MetricsWarehouse::INTERVAL_ALIASES keys. Drift between any of
|
|
29
|
+
# the three = picker rows that silently roundtrip to "auto".
|
|
30
|
+
OPTIONS = %w[auto 1s 10s 15s 1m 5m 15m 30m 1h].freeze
|
|
31
|
+
|
|
32
|
+
# current: currently active interval (e.g. "auto", "1m")
|
|
33
|
+
# base_path: URL each row hits (metrics_path or metrics_chart_path)
|
|
34
|
+
# extra_params: merged into every URL EXCEPT `interval` (which the
|
|
35
|
+
# picker overrides per row). Pass the request query
|
|
36
|
+
# minus `interval`.
|
|
37
|
+
# turbo_stream: emit data-turbo-stream on row anchors so the swap
|
|
38
|
+
# stays modal-local (used inside the modal toolbar).
|
|
39
|
+
# Defaults to false for the page-level picker which
|
|
40
|
+
# does a normal navigation.
|
|
41
|
+
def initialize(current:, base_path:, extra_params: {}, turbo_stream: false)
|
|
42
|
+
@current = current.to_s.presence || "auto"
|
|
43
|
+
@base_path = base_path
|
|
44
|
+
@extra_params = extra_params || {}
|
|
45
|
+
@turbo_stream = turbo_stream
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def view_template
|
|
49
|
+
div(class: "relative", data: {controller: "dropdown"}) do
|
|
50
|
+
trigger
|
|
51
|
+
menu
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def trigger
|
|
58
|
+
button(
|
|
59
|
+
type: "button",
|
|
60
|
+
data: {action: "click->dropdown#toggle"},
|
|
61
|
+
class: "inline-flex items-center gap-2 px-2.5 h-9 min-w-[140px] border border-clowk-border bg-clowk-surface text-clowk-text text-[12.5px] hover:bg-clowk-surface-2"
|
|
62
|
+
) do
|
|
63
|
+
span(class: "min-w-0 truncate") do
|
|
64
|
+
span(class: "text-clowk-muted") { "every " }
|
|
65
|
+
span(class: "font-clowk-mono text-clowk-text") { @current }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
div(class: "flex-1")
|
|
69
|
+
icon(:chevron_down, class: "w-2.5 h-2.5 text-clowk-muted")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def menu
|
|
74
|
+
div(
|
|
75
|
+
hidden: true,
|
|
76
|
+
data: {dropdown_target: "menu"},
|
|
77
|
+
class: "absolute left-0 top-[calc(100%+4px)] z-30 min-w-[160px] max-h-[360px] overflow-auto scrollbar-hidden border border-clowk-border-2 bg-clowk-surface shadow-2xl"
|
|
78
|
+
) do
|
|
79
|
+
OPTIONS.each { |opt| option_row(opt) }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def option_row(opt)
|
|
84
|
+
active = opt == @current
|
|
85
|
+
|
|
86
|
+
a(
|
|
87
|
+
href: build_url(opt),
|
|
88
|
+
data: @turbo_stream ? {turbo_stream: "true"} : {turbo: false},
|
|
89
|
+
class: tokens(
|
|
90
|
+
"flex items-center gap-2.5 w-full px-3 py-2 min-h-[34px] text-left",
|
|
91
|
+
active ? "bg-clowk-accent-dim text-clowk-accent-2" : "text-clowk-text hover:bg-clowk-hover"
|
|
92
|
+
)
|
|
93
|
+
) do
|
|
94
|
+
span(
|
|
95
|
+
class: tokens(
|
|
96
|
+
"font-clowk-mono text-[12.5px] truncate flex-1",
|
|
97
|
+
active ? "font-semibold text-clowk-accent-2" : "font-medium text-clowk-text"
|
|
98
|
+
)
|
|
99
|
+
) { opt }
|
|
100
|
+
|
|
101
|
+
if active
|
|
102
|
+
icon(:check, class: "w-3 h-3 text-clowk-accent-2 shrink-0 ml-1")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# build_url — picker rows override `interval` and leave the rest
|
|
108
|
+
# of the query untouched. `auto` is OMITTED from the URL so the
|
|
109
|
+
# default state reads as `?range=1h` instead of `?range=1h&interval=auto`.
|
|
110
|
+
def build_url(opt)
|
|
111
|
+
params = @extra_params.dup
|
|
112
|
+
params[:interval] = opt unless opt == "auto"
|
|
113
|
+
|
|
114
|
+
qs = params.to_query
|
|
115
|
+
qs.empty? ? @base_path.to_s : "#{@base_path}?#{qs}"
|
|
116
|
+
end
|
|
117
|
+
end
|