predictability-engine 0.6.6
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/bin/predictability-engine +19 -0
- data/bin/predictability-engine.bat +2 -0
- data/bin/setup +47 -0
- data/data/samples/sample_data.csv +19 -0
- data/data/samples/sample_data_large.csv +201 -0
- data/data/samples/wip_data.csv +5 -0
- data/lib/predictability_engine/agents/assistant.rb +29 -0
- data/lib/predictability_engine/agents/tools.rb +98 -0
- data/lib/predictability_engine/calculators/aging.rb +36 -0
- data/lib/predictability_engine/calculators/cfd.rb +80 -0
- data/lib/predictability_engine/calculators/cfd_forecaster.rb +112 -0
- data/lib/predictability_engine/calculators/cycle_time.rb +26 -0
- data/lib/predictability_engine/calculators/throughput.rb +42 -0
- data/lib/predictability_engine/cli.rb +414 -0
- data/lib/predictability_engine/config.rb +167 -0
- data/lib/predictability_engine/data_generator.rb +53 -0
- data/lib/predictability_engine/data_manager.rb +28 -0
- data/lib/predictability_engine/data_sources/base.rb +93 -0
- data/lib/predictability_engine/data_sources/csv.rb +62 -0
- data/lib/predictability_engine/data_sources/excel.rb +18 -0
- data/lib/predictability_engine/data_sources/factory.rb +20 -0
- data/lib/predictability_engine/data_sources/jira.rb +201 -0
- data/lib/predictability_engine/data_sources/jira_yaml.rb +103 -0
- data/lib/predictability_engine/duration.rb +16 -0
- data/lib/predictability_engine/excel_exporter.rb +48 -0
- data/lib/predictability_engine/html_style.rb +63 -0
- data/lib/predictability_engine/html_templates.rb +70 -0
- data/lib/predictability_engine/jira_auth/base.rb +26 -0
- data/lib/predictability_engine/jira_auth/basic.rb +15 -0
- data/lib/predictability_engine/jira_auth/bearer.rb +11 -0
- data/lib/predictability_engine/jira_auth/cookie.rb +15 -0
- data/lib/predictability_engine/jira_auth/mfa_api.rb +36 -0
- data/lib/predictability_engine/jira_auth/mfa_browser.rb +85 -0
- data/lib/predictability_engine/jira_auth.rb +22 -0
- data/lib/predictability_engine/jira_config_prompter.rb +48 -0
- data/lib/predictability_engine/jira_workflow.rb +137 -0
- data/lib/predictability_engine/logger.rb +45 -0
- data/lib/predictability_engine/mermaid_visualizer.rb +94 -0
- data/lib/predictability_engine/models/work_item.rb +43 -0
- data/lib/predictability_engine/pdf_visualizer/primitives.rb +83 -0
- data/lib/predictability_engine/pdf_visualizer.rb +64 -0
- data/lib/predictability_engine/raw_data_exporter.rb +46 -0
- data/lib/predictability_engine/report/constants.rb +70 -0
- data/lib/predictability_engine/report/image_generator.rb +36 -0
- data/lib/predictability_engine/report/text_renderer.rb +84 -0
- data/lib/predictability_engine/report.rb +328 -0
- data/lib/predictability_engine/report_generator.rb +170 -0
- data/lib/predictability_engine/setup_manager.rb +127 -0
- data/lib/predictability_engine/simulators/monte_carlo.rb +57 -0
- data/lib/predictability_engine/simulators/monte_carlo_validator.rb +85 -0
- data/lib/predictability_engine/summary_visualizer/helpers.rb +71 -0
- data/lib/predictability_engine/summary_visualizer/renderer.rb +88 -0
- data/lib/predictability_engine/summary_visualizer.rb +36 -0
- data/lib/predictability_engine/terminal_visualizer/cfd_renderer.rb +52 -0
- data/lib/predictability_engine/terminal_visualizer.rb +97 -0
- data/lib/predictability_engine/vega_visualizer/aging_wip_visualizer.rb +44 -0
- data/lib/predictability_engine/vega_visualizer/basic_charts.rb +121 -0
- data/lib/predictability_engine/vega_visualizer/cfd_charts.rb +82 -0
- data/lib/predictability_engine/vega_visualizer/cfd_layout.rb +132 -0
- data/lib/predictability_engine/vega_visualizer/tooltip_helpers.rb +34 -0
- data/lib/predictability_engine/vega_visualizer.rb +106 -0
- data/lib/predictability_engine/version.rb +5 -0
- data/lib/predictability_engine/visualizer.rb +114 -0
- data/lib/predictability_engine.rb +117 -0
- metadata +566 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PredictabilityEngine
|
|
4
|
+
class Report
|
|
5
|
+
module TextRenderer
|
|
6
|
+
def self.render(report, fmt, color: false, layout: :standard, **)
|
|
7
|
+
config = Constants::FORMAT_CONFIG[fmt]
|
|
8
|
+
header = config[:h1].call(report.title)
|
|
9
|
+
if layout.to_sym == :landscape
|
|
10
|
+
return [header, '',
|
|
11
|
+
render_landscape(report, fmt, color: color, **)].join("\n")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
[header, SummaryVisualizer.render(report.items, fmt, color: color, percentiles: report.percentiles),
|
|
15
|
+
*Constants::CHART_CONFIG.map do |id, cfg|
|
|
16
|
+
render_section(report, id, cfg, fmt, color: color, **)
|
|
17
|
+
end].join("\n")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.render_landscape(report, fmt, color: false, **opts)
|
|
21
|
+
sum = SummaryVisualizer.render(report.items, fmt, color: color, percentiles: report.percentiles)
|
|
22
|
+
section_opts = opts.merge(color: color, table: true)
|
|
23
|
+
ch = Constants::CHART_CONFIG.map { |id, c| render_section(report, id, c, fmt, section_opts) }
|
|
24
|
+
br = fmt == :confluence ? ' \\\\ ' : '<br>'
|
|
25
|
+
bold = fmt == :confluence ? '*' : '**'
|
|
26
|
+
h_regex = fmt == :confluence ? /^h\d\.\s*(.*)$/ : /^#+\s*(.*)$/
|
|
27
|
+
clean_sum = sum.gsub(h_regex, "#{bold}\\1#{bold}").gsub("\n\n", br).gsub("\n", br)
|
|
28
|
+
clean_ch = ch.map { |c| c.gsub("\n", br) }
|
|
29
|
+
|
|
30
|
+
build_table(fmt, clean_sum, clean_ch)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.build_table(fmt, sum, charts)
|
|
34
|
+
row1 = "| #{sum} | #{charts[0]} | #{charts[1]} | #{charts[2]} |"
|
|
35
|
+
span = fmt == :confluence ? '^' : nil
|
|
36
|
+
row2 = "| #{span} | #{charts[3]} | #{charts[4]} | #{charts[5]} |"
|
|
37
|
+
md_hdr = "| | | | |\n| :--- | :--- | :--- | :--- |"
|
|
38
|
+
[(fmt == :markdown ? md_hdr : nil), row1, row2].compact.join("\n")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.render_section(report, chart_id, cfg, fmt, options = {})
|
|
42
|
+
title = section_title(cfg[:title], fmt, options[:table])
|
|
43
|
+
if fmt != :terminal && image_available?(report, chart_id)
|
|
44
|
+
[title, report.render_image_link(chart_id, fmt)].join("\n")
|
|
45
|
+
elsif %i[markdown confluence].include?(fmt) && MermaidVisualizer.respond_to?(chart_id)
|
|
46
|
+
render_mermaid(report, chart_id, fmt, title)
|
|
47
|
+
else
|
|
48
|
+
render_ascii(report, chart_id, Constants::FORMAT_CONFIG[fmt], options[:color], title,
|
|
49
|
+
**options.except(:color, :table))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.section_title(title, fmt, table)
|
|
54
|
+
bold = fmt == :confluence ? '*' : '**'
|
|
55
|
+
table ? "#{bold}#{title}#{bold}" : Constants::FORMAT_CONFIG[fmt][:h2].call(title)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.image_available?(report, chart_id)
|
|
59
|
+
report.images_path && File.exist?(File.join(report.images_path, "#{chart_id}.png"))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.render_mermaid(report, chart_id, fmt, title)
|
|
63
|
+
wrap = fmt == :confluence ? ['{mermaid}', '{mermaid}'] : ['```mermaid', '```']
|
|
64
|
+
[title, wrap[0], MermaidVisualizer.send(chart_id, report.items, percentiles: report.percentiles),
|
|
65
|
+
wrap[1], render_legend(chart_id, report.percentiles)].compact.join("\n")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.render_ascii(report, chart_id, config, color, title, **opts) # rubocop:disable Metrics/ParameterLists
|
|
69
|
+
call_opts = opts.merge(percentiles: report.percentiles, color: color || false)
|
|
70
|
+
[title, config[:code].call(title, TerminalVisualizer.send(chart_id, report.items, **call_opts))].join("\n")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.render_legend(chart_id, pcts)
|
|
74
|
+
return nil unless chart_id == :forecasted_cfd_plot
|
|
75
|
+
|
|
76
|
+
"\n**Legend:** Arrivals (blue), Departures (green), Projections (various colors). " \
|
|
77
|
+
"Vertical lines for: #{pcts.join('%, ')}% confidence."
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private_class_method :render_landscape, :build_table, :render_section, :section_title,
|
|
81
|
+
:image_available?, :render_mermaid, :render_ascii, :render_legend
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require_relative 'report/constants'
|
|
5
|
+
require_relative 'report/image_generator'
|
|
6
|
+
require_relative 'report/text_renderer'
|
|
7
|
+
|
|
8
|
+
module PredictabilityEngine
|
|
9
|
+
class Report # rubocop:disable Metrics/ClassLength
|
|
10
|
+
include Constants
|
|
11
|
+
|
|
12
|
+
PRIORITY_SORT = lambda do |values|
|
|
13
|
+
values.sort_by { |v| [Constants::PRIORITY_ORDER.index(v) || Constants::PRIORITY_ORDER.size, v] }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
FACETS = [
|
|
17
|
+
{ key: :priority, label: 'Priority', accessor: :priority, dirname: 'priorities',
|
|
18
|
+
sort: PRIORITY_SORT },
|
|
19
|
+
{ key: :type, label: 'Type', accessor: :type, dirname: 'types',
|
|
20
|
+
sort: lambda(&:sort) }
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :items, :title, :percentiles, :images_path, :type, :priority
|
|
24
|
+
|
|
25
|
+
def initialize(items, title: 'Predictability Report', percentiles: PredictabilityEngine::DEFAULT_PERCENTILES,
|
|
26
|
+
type: nil, priority: nil)
|
|
27
|
+
@type = type
|
|
28
|
+
@priority = priority
|
|
29
|
+
@items = filter_by_facets(items)
|
|
30
|
+
@title = title
|
|
31
|
+
@percentiles = percentiles
|
|
32
|
+
@images_path = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.generate_all(items)
|
|
36
|
+
reports = { all: new(items, title: 'Full Predictability Dashboard') }
|
|
37
|
+
FACETS.each do |facet|
|
|
38
|
+
values = facet_values(items, facet)
|
|
39
|
+
next unless values.size > 1
|
|
40
|
+
|
|
41
|
+
reports[facet[:key]] = values.to_h do |val|
|
|
42
|
+
[val, new(items, title: "Dashboard: #{val}", facet[:key] => val)]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
reports
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.facet_values(items, facet)
|
|
49
|
+
raw = items.map { |i| i.public_send(facet[:accessor]) || 'Unspecified' }.uniq
|
|
50
|
+
facet[:sort].call(raw)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def render(format, layout: nil, color: false, **extra_opts)
|
|
54
|
+
config = find_format_config(format)&.last
|
|
55
|
+
method_name = "render_#{find_format_config(format)&.first || format.to_sym}"
|
|
56
|
+
raise ArgumentError, "Unsupported format: #{format}" unless respond_to?(method_name, true)
|
|
57
|
+
|
|
58
|
+
opts = { layout: effective_layout(layout, config), color: color }.merge(extra_opts)
|
|
59
|
+
opts[:format] = config[:format] if config&.key?(:format)
|
|
60
|
+
opts[:landscape] = config[:landscape] if config&.key?(:landscape)
|
|
61
|
+
send(method_name, **opts)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def generate_chart_images(base_dir, **)
|
|
65
|
+
@images_path = ImageGenerator.generate(self, base_dir, **)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def render_html(layout: :landscape, sub_reports: nil, **)
|
|
69
|
+
charts = CHART_CONFIG.map do |id, cfg|
|
|
70
|
+
chart = VegaVisualizer.send(cfg[:vega] || id, @items,
|
|
71
|
+
title: nil,
|
|
72
|
+
percentiles: @percentiles)
|
|
73
|
+
{ title: cfg[:title], chart: chart }
|
|
74
|
+
end
|
|
75
|
+
Visualizer.to_full_html(charts, @items, title: @title, layout: layout, percentiles: @percentiles,
|
|
76
|
+
sub_reports: sub_reports)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def with_report_temp_html(layout: :landscape)
|
|
80
|
+
temp_html = "tmp/report_#{object_id}.html"
|
|
81
|
+
FileUtils.mkdir_p('tmp')
|
|
82
|
+
File.write(temp_html, render_html(layout: layout))
|
|
83
|
+
yield temp_html
|
|
84
|
+
ensure
|
|
85
|
+
FileUtils.rm_f(temp_html) if temp_html
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def playwright_bin
|
|
89
|
+
root = File.expand_path('../..', __dir__)
|
|
90
|
+
unless ENV['PLAYWRIGHT_BROWSERS_PATH']
|
|
91
|
+
require 'etc'
|
|
92
|
+
real_home = begin
|
|
93
|
+
Etc.getpwuid.dir
|
|
94
|
+
rescue StandardError
|
|
95
|
+
Dir.home
|
|
96
|
+
end
|
|
97
|
+
ENV['PLAYWRIGHT_BROWSERS_PATH'] = File.expand_path('.cache/ms-playwright', real_home)
|
|
98
|
+
end
|
|
99
|
+
File.exist?("#{root}/node_modules/.bin/playwright") ? "#{root}/node_modules/.bin/playwright" : 'npx playwright'
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def playwright_chromium_launch_opts
|
|
103
|
+
exe = ENV.fetch('PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH', nil)
|
|
104
|
+
return {} unless exe
|
|
105
|
+
|
|
106
|
+
# System Chromium (e.g. Alpine apk) needs --no-sandbox in Docker containers.
|
|
107
|
+
{ executablePath: exe, chromiumSandbox: false }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def pdf_viewport_size(format, landscape)
|
|
111
|
+
res = RESOLUTION_CONFIG[format.to_s.downcase]
|
|
112
|
+
if res
|
|
113
|
+
return landscape ? res : [res[1], res[0]]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
sizes = { 'A4' => [794, 1123], 'A3' => [1123, 1587] }
|
|
117
|
+
w, h = sizes[format.to_s.upcase] || sizes[DEFAULT_SIZE.to_s.upcase]
|
|
118
|
+
landscape ? [h, w] : [w, h]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def render_image_link(chart_id, fmt)
|
|
122
|
+
rel_path = "images/#{chart_id}.png"
|
|
123
|
+
fmt == :confluence ? "!#{rel_path}!" : ""
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def filter_by_facets(items)
|
|
129
|
+
FACETS.reduce(items) do |acc, facet|
|
|
130
|
+
filter_val = instance_variable_get("@#{facet[:key]}")
|
|
131
|
+
next acc unless filter_val
|
|
132
|
+
|
|
133
|
+
acc.select { |i| (i.public_send(facet[:accessor]) || 'Unspecified') == filter_val }
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def render_ppt_multi_slide
|
|
138
|
+
require 'powerpoint'
|
|
139
|
+
base_dir = "tmp/ppt_#{object_id}"
|
|
140
|
+
FileUtils.mkdir_p(base_dir)
|
|
141
|
+
generate_chart_images_or_warn(base_dir)
|
|
142
|
+
|
|
143
|
+
deck = Powerpoint::Presentation.new
|
|
144
|
+
deck.add_intro(@title, "Generated on #{PredictabilityEngine.format_datetime(Time.now)}")
|
|
145
|
+
|
|
146
|
+
metrics = SummaryVisualizer.metrics_terminal(@items, color: false, percentiles: @percentiles)
|
|
147
|
+
deck.add_textual_slide('Flow Metrics Summary', metrics.split("\n").map(&:strip).reject(&:empty?))
|
|
148
|
+
|
|
149
|
+
CHART_CONFIG.each_key do |id|
|
|
150
|
+
img = File.join(@images_path, "#{id}.png") if @images_path
|
|
151
|
+
deck.add_pictorial_slide(CHART_CONFIG[id][:title], img) if img && File.exist?(img)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
ppt_file = File.join(base_dir, 'dashboard.pptx')
|
|
155
|
+
deck.save(ppt_file)
|
|
156
|
+
File.binread(ppt_file)
|
|
157
|
+
ensure
|
|
158
|
+
FileUtils.rm_rf(base_dir) if base_dir
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def generate_chart_images_or_warn(base_dir, **)
|
|
162
|
+
generate_chart_images(base_dir, **)
|
|
163
|
+
rescue StandardError => e
|
|
164
|
+
PredictabilityEngine.warn_chart_failure(e, context: 'Slides will be text-only.')
|
|
165
|
+
@images_path = nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def find_format_config(format)
|
|
169
|
+
fmt = format.to_sym
|
|
170
|
+
FORMAT_CONFIG.find { |k, v| k == fmt || v[:aliases]&.include?(fmt) }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def effective_layout(layout, config) = (layout || config&.dig(:layout) || :landscape).to_sym
|
|
174
|
+
|
|
175
|
+
%i[terminal markdown confluence].each do |f|
|
|
176
|
+
define_method("render_#{f}") { |**o| TextRenderer.render(self, f, **o) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def render_raw_csv(**)
|
|
180
|
+
require_relative 'raw_data_exporter'
|
|
181
|
+
RawDataExporter.generate_csv(@items)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def render_xlsx(**)
|
|
185
|
+
require_relative 'excel_exporter'
|
|
186
|
+
Dir.mktmpdir('pe_xlsx_') do |dir|
|
|
187
|
+
generate_chart_images(dir, width: ExcelExporter::CAPTURE_WIDTH,
|
|
188
|
+
height: ExcelExporter::CAPTURE_HEIGHT,
|
|
189
|
+
scale: ExcelExporter::CHART_SCALE)
|
|
190
|
+
ExcelExporter.generate(@items, images_path: @images_path)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def render_landscape(layout: :landscape, **) = render_html(layout: layout, **)
|
|
195
|
+
def render_a3_landscape(**) = render_pdf(**)
|
|
196
|
+
|
|
197
|
+
def render_png(layout: :landscape, size: DEFAULT_SIZE, **)
|
|
198
|
+
res = RESOLUTION_CONFIG[size.to_s.downcase] || RESOLUTION_CONFIG[DEFAULT_SIZE]
|
|
199
|
+
with_report_temp_html(layout: layout) do |temp_html|
|
|
200
|
+
png_data = nil
|
|
201
|
+
with_playwright_page(temp_html, width: res[0], height: res[1]) do |page|
|
|
202
|
+
png_data = page.screenshot
|
|
203
|
+
end
|
|
204
|
+
png_data
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def render_ppt(size: DEFAULT_SIZE, **_opts)
|
|
209
|
+
require 'powerpoint'
|
|
210
|
+
res = RESOLUTION_CONFIG[size.to_s.downcase] || RESOLUTION_CONFIG[DEFAULT_SIZE]
|
|
211
|
+
temp_img = "tmp/dashboard_#{object_id}.png"
|
|
212
|
+
ppt_file = "tmp/dashboard_#{object_id}.pptx"
|
|
213
|
+
|
|
214
|
+
with_report_temp_html(layout: :landscape) do |temp_html|
|
|
215
|
+
capture_screenshot(temp_html, temp_img, width: res[0], height: res[1])
|
|
216
|
+
|
|
217
|
+
deck = Powerpoint::Presentation.new
|
|
218
|
+
deck.add_pictorial_slide(@title, temp_img)
|
|
219
|
+
deck.save(ppt_file)
|
|
220
|
+
File.binread(ppt_file)
|
|
221
|
+
end
|
|
222
|
+
rescue StandardError => e
|
|
223
|
+
PredictabilityEngine.logger.warn do
|
|
224
|
+
"High-fidelity PPT generation failed: #{e.message}. Falling back to multi-slide."
|
|
225
|
+
end
|
|
226
|
+
render_ppt_multi_slide
|
|
227
|
+
ensure
|
|
228
|
+
FileUtils.rm_f(temp_img) if temp_img
|
|
229
|
+
FileUtils.rm_f(ppt_file) if ppt_file
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def capture_screenshot(html_path, img_path, width: 1280, height: 720)
|
|
233
|
+
with_playwright_page(html_path, width: width, height: height) do |page|
|
|
234
|
+
page.screenshot(path: img_path, fullPage: true)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def render_pdf(layout: :landscape, high_fidelity: true, format: nil, size: DEFAULT_SIZE, landscape: true, **_opts) # rubocop:disable Metrics/ParameterLists
|
|
239
|
+
fmt = format || size
|
|
240
|
+
high_fidelity ? render_pdf_playwright(layout: layout, format: fmt, landscape: landscape) : render_pdf_prawn
|
|
241
|
+
rescue StandardError => e
|
|
242
|
+
PredictabilityEngine.logger.warn { "High-fidelity PDF generation failed: #{e.message}. Falling back to Prawn." }
|
|
243
|
+
render_pdf_prawn
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def render_pdf_playwright(layout: :landscape, format: DEFAULT_SIZE, landscape: true)
|
|
247
|
+
with_report_temp_html(layout: layout) do |temp_html|
|
|
248
|
+
capture_pdf(temp_html, format, landscape)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def capture_pdf(html_path, format, landscape)
|
|
253
|
+
w, h = pdf_viewport_size(format, landscape)
|
|
254
|
+
pdf_data = nil
|
|
255
|
+
with_playwright_page(html_path, width: w, height: h) do |page|
|
|
256
|
+
pdf_opts = { printBackground: true,
|
|
257
|
+
margin: { top: '0', right: '0', bottom: '0', left: '0' },
|
|
258
|
+
width: "#{w}px", height: "#{h}px" }
|
|
259
|
+
pdf_data = page.pdf(**pdf_opts)
|
|
260
|
+
end
|
|
261
|
+
pdf_data
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def with_playwright_page(html_path, width: 1280, height: 720)
|
|
265
|
+
require 'playwright'
|
|
266
|
+
Playwright.create(playwright_cli_executable_path: playwright_bin) do |p|
|
|
267
|
+
p.chromium.launch(**playwright_chromium_launch_opts) do |browser|
|
|
268
|
+
page = browser.new_page(viewport: { width: width, height: height })
|
|
269
|
+
route_vega_cdn(page)
|
|
270
|
+
page.goto("file://#{File.expand_path(html_path)}")
|
|
271
|
+
sleep 2 # wait for Vega to render
|
|
272
|
+
yield page
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def route_vega_cdn(page)
|
|
278
|
+
js_dir = File.join(Gem.loaded_specs['vega'].gem_dir, 'vendor', 'assets', 'javascripts')
|
|
279
|
+
{ '**/npm/vega@5**' => 'vega.js', '**/npm/vega-lite@5**' => 'vega-lite.js',
|
|
280
|
+
'**/npm/vega-embed@6**' => 'vega-embed.js' }.each do |pattern, filename|
|
|
281
|
+
content = File.binread(File.join(js_dir, filename)).force_encoding('UTF-8')
|
|
282
|
+
page.route(pattern, ->(route, _req) { route.fulfill(body: content, contentType: 'application/javascript') })
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def render_pdf_prawn(**)
|
|
287
|
+
require 'prawn'
|
|
288
|
+
Prawn::Document.new(page_layout: :landscape, margin: 30) do |pdf|
|
|
289
|
+
Report.send(:setup_pdf_font_on_doc, pdf)
|
|
290
|
+
pdf.text @title, size: 20, style: :bold
|
|
291
|
+
pdf.move_down 15
|
|
292
|
+
|
|
293
|
+
pdf.define_grid(columns: 3, rows: 2, gutter: 15)
|
|
294
|
+
|
|
295
|
+
pdf.grid([0, 0], [1, 0]).bounding_box do
|
|
296
|
+
pdf.text 'Flow Metrics Summary', size: 14, style: :bold
|
|
297
|
+
pdf.move_down 10
|
|
298
|
+
pdf.text SummaryVisualizer.metrics_terminal(@items, color: false, percentiles: @percentiles), size: 8
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
charts = CHART_CONFIG.keys
|
|
302
|
+
[[0, 1], [0, 2], [1, 1], [1, 2]].each_with_index do |grid_pos, i|
|
|
303
|
+
id = charts[i]
|
|
304
|
+
next unless id
|
|
305
|
+
|
|
306
|
+
pdf.grid(*grid_pos).bounding_box do
|
|
307
|
+
pdf.text CHART_CONFIG[id][:title], size: 10, style: :bold
|
|
308
|
+
pdf.move_down 5
|
|
309
|
+
PdfVisualizer.draw_chart(pdf, id, @items, percentiles: @percentiles)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end.render
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def self.setup_pdf_font_on_doc(pdf)
|
|
316
|
+
mono = find_font(Constants::FONT_PATHS)
|
|
317
|
+
bold = find_font(Constants::FONT_BOLD_PATHS)
|
|
318
|
+
return unless mono && bold
|
|
319
|
+
|
|
320
|
+
pdf.font_families.update('CustomMono' => { normal: mono, bold: bold })
|
|
321
|
+
pdf.font 'CustomMono'
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def self.find_font(paths) = paths.find { |p| File.exist?(p) }
|
|
325
|
+
|
|
326
|
+
private_class_method :setup_pdf_font_on_doc, :find_font
|
|
327
|
+
end
|
|
328
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module PredictabilityEngine
|
|
6
|
+
# Logic for generating and writing reports.
|
|
7
|
+
module ReportGenerator # rubocop:disable Metrics/ModuleLength
|
|
8
|
+
def self.run_report(file, format, items: nil, reports: nil, **)
|
|
9
|
+
items ||= PredictabilityEngine.load_items(file, **)
|
|
10
|
+
reports ||= Report.generate_all(items)
|
|
11
|
+
|
|
12
|
+
if facet_total(reports).zero? || format.to_sym == :terminal
|
|
13
|
+
generate_single_report(file, format, reports[:all], **)
|
|
14
|
+
else
|
|
15
|
+
generate_multi_reports(file, format, reports, **)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.facet_total(reports)
|
|
20
|
+
Report::FACETS.sum { |f| (reports[f[:key]] || {}).size }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.each_facet_entry(reports)
|
|
24
|
+
yield :all, reports[:all]
|
|
25
|
+
Report::FACETS.each do |facet|
|
|
26
|
+
(reports[facet[:key]] || {}).each { |value, report| yield [facet[:key], value], report }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.generate_single_report(file, format, report, **opts)
|
|
31
|
+
fmt = format.to_sym
|
|
32
|
+
generate_images_if_needed(file, fmt, report, **opts)
|
|
33
|
+
opts = opts.merge(sub_reports: export_links_for(:all)) if html_format?(fmt) && !opts[:sub_reports]
|
|
34
|
+
|
|
35
|
+
content = report.render(fmt, **opts)
|
|
36
|
+
if opts[:output] || fmt != :terminal
|
|
37
|
+
write_report(file, format, content, opts[:output], **opts)
|
|
38
|
+
else
|
|
39
|
+
content
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.generate_multi_reports(file, format, reports, **opts)
|
|
44
|
+
fmt = format.to_sym
|
|
45
|
+
msgs = []
|
|
46
|
+
each_facet_entry(reports) do |slot, report|
|
|
47
|
+
generate_images_if_needed(file, fmt, report, **opts)
|
|
48
|
+
links = build_nav_links(fmt, reports, slot)
|
|
49
|
+
content = report.render(fmt, sub_reports: links, **opts)
|
|
50
|
+
msg = write_report(file, format, content, opts[:output], slot: slot, **opts)
|
|
51
|
+
PredictabilityEngine.logger.info { msg }
|
|
52
|
+
msgs << msg
|
|
53
|
+
end
|
|
54
|
+
"#{msgs.size} reports generated"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.generate_images_if_needed(file, format, report, **)
|
|
58
|
+
return unless %i[markdown md confluence conf].include?(format)
|
|
59
|
+
|
|
60
|
+
dir = report_dir(file, **)
|
|
61
|
+
report.generate_chart_images(dir)
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
PredictabilityEngine.warn_chart_failure(e, context: 'Inline images will be omitted.')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.build_nav_links(format, reports, current_slot)
|
|
67
|
+
return unless html_format?(format)
|
|
68
|
+
|
|
69
|
+
links = [nav_entry(:all, current_slot, label: 'All')]
|
|
70
|
+
Report::FACETS.each do |facet|
|
|
71
|
+
values = (reports[facet[:key]] || {}).keys
|
|
72
|
+
next if values.empty?
|
|
73
|
+
|
|
74
|
+
links << { separator: true }
|
|
75
|
+
values.each { |value| links << nav_entry([facet[:key], value], current_slot, label: value) }
|
|
76
|
+
end
|
|
77
|
+
links + export_links_for(current_slot)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.html_format?(format)
|
|
81
|
+
%i[html landscape].include?(format)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.export_links_for(slot)
|
|
85
|
+
prefix = slot.is_a?(Array) ? '../' : ''
|
|
86
|
+
[{ label: 'CSV', url: "#{prefix}dashboard.csv", download: true },
|
|
87
|
+
{ label: 'XLSX', url: "#{prefix}dashboard.xlsx", download: true }]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.nav_entry(slot, current_slot, label:)
|
|
91
|
+
{ label: label, url: nav_url(slot, current_slot), active: slot == current_slot }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.nav_url(slot, current_slot)
|
|
95
|
+
return main_dashboard_url(current_slot) if slot == :all
|
|
96
|
+
|
|
97
|
+
target_facet_key, value = slot
|
|
98
|
+
target_dir = facet_dirname(target_facet_key)
|
|
99
|
+
return "#{value}.html" if current_slot.is_a?(Array) && current_slot[0] == target_facet_key
|
|
100
|
+
return "../#{target_dir}/#{value}.html" if current_slot.is_a?(Array)
|
|
101
|
+
|
|
102
|
+
"#{target_dir}/#{value}.html"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.main_dashboard_url(current_slot)
|
|
106
|
+
current_slot.is_a?(Array) ? '../dashboard.html' : 'dashboard.html'
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def self.facet_dirname(facet_key)
|
|
110
|
+
Report::FACETS.find { |f| f[:key] == facet_key }[:dirname]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.write_report(file, format, content, output, slot: :all, **) # rubocop:disable Metrics/ParameterLists
|
|
114
|
+
ext = format_to_ext(format.to_sym)
|
|
115
|
+
dir = report_dir(file, **)
|
|
116
|
+
|
|
117
|
+
if slot == :all
|
|
118
|
+
FileUtils.mkdir_p(dir) unless output || File.exist?(dir)
|
|
119
|
+
output ||= File.join(dir, dashboard_filename(format.to_sym, ext))
|
|
120
|
+
else
|
|
121
|
+
facet_key, value = slot
|
|
122
|
+
dir = File.join(dir, facet_dirname(facet_key))
|
|
123
|
+
FileUtils.mkdir_p(dir)
|
|
124
|
+
output = File.join(dir, "#{value}.#{ext}")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
File.binwrite(output, content)
|
|
128
|
+
"Report generated at #{output}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.report_dir(file, **opts)
|
|
132
|
+
base_dir = if opts[:output_dir]
|
|
133
|
+
opts[:output_dir]
|
|
134
|
+
else
|
|
135
|
+
input_dir = File.dirname(file)
|
|
136
|
+
input_dir == '.' ? 'reports' : File.join(input_dir, 'reports')
|
|
137
|
+
end
|
|
138
|
+
Pathname.new(File.join(base_dir, File.basename(file, '.*'))).cleanpath.to_s
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def self.clean_report_dir(file, **)
|
|
142
|
+
dir = report_dir(file, **)
|
|
143
|
+
FileUtils.rm_rf(dir)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def self.dashboard_filename(format, ext)
|
|
147
|
+
case format
|
|
148
|
+
when :html, :landscape, :dashboard then 'dashboard.html'
|
|
149
|
+
when :a3_landscape then 'dashboard_a3.pdf'
|
|
150
|
+
when :png then 'dashboard.png'
|
|
151
|
+
else "dashboard.#{ext}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
FORMAT_TO_EXT = {
|
|
156
|
+
markdown: 'md', md: 'md',
|
|
157
|
+
confluence: 'conf', conf: 'conf',
|
|
158
|
+
landscape: 'html', dashboard: 'html',
|
|
159
|
+
a3_landscape: 'pdf',
|
|
160
|
+
ppt: 'pptx',
|
|
161
|
+
png: 'png',
|
|
162
|
+
raw_csv: 'csv',
|
|
163
|
+
xlsx: 'xlsx'
|
|
164
|
+
}.freeze
|
|
165
|
+
|
|
166
|
+
def self.format_to_ext(format)
|
|
167
|
+
FORMAT_TO_EXT[format] || format.to_s
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|