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