familia 1.2.1 → 2.0.0.pre2
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 +4 -4
- data/.github/workflows/ci.yml +68 -0
- data/.github/workflows/docs.yml +64 -0
- data/.gitignore +4 -0
- data/.pre-commit-config.yaml +3 -1
- data/.rubocop.yml +16 -9
- data/.rubocop_todo.yml +177 -31
- data/.yardopts +9 -0
- data/CLAUDE.md +141 -0
- data/Gemfile +16 -2
- data/Gemfile.lock +97 -36
- data/README.md +39 -23
- data/bin/irb +3 -0
- data/docs/connection_pooling.md +192 -0
- data/familia.gemspec +10 -6
- data/lib/familia/base.rb +19 -9
- data/lib/familia/connection.rb +232 -65
- data/lib/familia/core_ext.rb +1 -1
- data/lib/familia/datatype/commands.rb +59 -0
- data/lib/familia/{redistype → datatype}/serialization.rb +9 -13
- data/lib/familia/{redistype → datatype}/types/hashkey.rb +25 -25
- data/lib/familia/{redistype → datatype}/types/list.rb +13 -13
- data/lib/familia/{redistype → datatype}/types/sorted_set.rb +20 -20
- data/lib/familia/{redistype → datatype}/types/string.rb +22 -21
- data/lib/familia/{redistype → datatype}/types/unsorted_set.rb +11 -11
- data/lib/familia/datatype.rb +243 -0
- data/lib/familia/errors.rb +5 -2
- data/lib/familia/features/expiration.rb +33 -34
- data/lib/familia/features/quantization.rb +9 -3
- data/lib/familia/features/safe_dump.rb +2 -3
- data/lib/familia/features.rb +2 -2
- data/lib/familia/horreum/class_methods.rb +97 -110
- data/lib/familia/horreum/commands.rb +46 -51
- data/lib/familia/horreum/connection.rb +82 -0
- data/lib/familia/horreum/{relations_management.rb → related_fields_management.rb} +37 -35
- data/lib/familia/horreum/serialization.rb +61 -198
- data/lib/familia/horreum/settings.rb +6 -17
- data/lib/familia/horreum/utils.rb +11 -10
- data/lib/familia/horreum.rb +69 -60
- data/lib/familia/logging.rb +12 -12
- data/lib/familia/multi_result.rb +72 -0
- data/lib/familia/refinements.rb +7 -44
- data/lib/familia/settings.rb +11 -11
- data/lib/familia/utils.rb +123 -90
- data/lib/familia/version.rb +4 -21
- data/lib/familia.rb +18 -13
- data/lib/middleware/database_middleware.rb +150 -0
- data/try/configuration/scenarios_try.rb +65 -0
- data/try/core/connection_try.rb +58 -0
- data/try/core/errors_try.rb +93 -0
- data/try/core/extensions_try.rb +26 -0
- data/try/{10_familia_try.rb → core/familia_extended_try.rb} +11 -10
- data/try/{00_familia_try.rb → core/familia_try.rb} +7 -5
- data/try/core/middleware_try.rb +68 -0
- data/try/core/refinements_try.rb +39 -0
- data/try/core/settings_try.rb +76 -0
- data/try/core/tools_try.rb +54 -0
- data/try/core/utils_try.rb +189 -0
- data/try/{26_redis_bool_try.rb → datatypes/boolean_try.rb} +4 -2
- data/try/datatypes/datatype_base_try.rb +69 -0
- data/try/{25_redis_type_hash_try.rb → datatypes/hash_try.rb} +5 -3
- data/try/{23_redis_type_list_try.rb → datatypes/list_try.rb} +5 -3
- data/try/{22_redis_type_set_try.rb → datatypes/set_try.rb} +5 -3
- data/try/{21_redis_type_zset_try.rb → datatypes/sorted_set_try.rb} +6 -4
- data/try/{24_redis_type_string_try.rb → datatypes/string_try.rb} +8 -8
- data/try/edge_cases/empty_identifiers_try.rb +48 -0
- data/try/{92_symbolize_try.rb → edge_cases/hash_symbolization_try.rb} +12 -7
- data/try/edge_cases/json_serialization_try.rb +85 -0
- data/try/edge_cases/race_conditions_try.rb +60 -0
- data/try/edge_cases/reserved_keywords_try.rb +59 -0
- data/try/{93_string_coercion_try.rb → edge_cases/string_coercion_try.rb} +60 -59
- data/try/edge_cases/ttl_side_effects_try.rb +51 -0
- data/try/features/expiration_try.rb +86 -0
- data/try/features/quantization_try.rb +90 -0
- data/try/{35_feature_safedump_try.rb → features/safe_dump_advanced_try.rb} +7 -6
- data/try/features/safe_dump_try.rb +137 -0
- data/try/{test_helpers.rb → helpers/test_helpers.rb} +25 -60
- data/try/{27_redis_horreum_try.rb → horreum/base_try.rb} +39 -14
- data/try/horreum/class_methods_try.rb +41 -0
- data/try/horreum/commands_try.rb +49 -0
- data/try/{29_redis_horreum_initialization_try.rb → horreum/initialization_try.rb} +9 -7
- data/try/horreum/relations_try.rb +146 -0
- data/try/{28_redis_horreum_serialization_try.rb → horreum/serialization_try.rb} +13 -11
- data/try/horreum/settings_try.rb +43 -0
- data/try/integration/cross_component_try.rb +46 -0
- data/try/{41_customer_safedump_try.rb → models/customer_safe_dump_try.rb} +9 -7
- data/try/{40_customer_try.rb → models/customer_try.rb} +21 -18
- data/try/models/datatype_base_try.rb +100 -0
- data/try/{30_familia_object_try.rb → models/familia_object_try.rb} +18 -16
- data/try/performance/benchmarks_try.rb +55 -0
- data/try/pooling/README.md +20 -0
- data/try/pooling/configurable_stress_test_try.rb +435 -0
- data/try/pooling/connection_pool_test_try.rb +273 -0
- data/try/pooling/lib/atomic_saves_v3_connection_pool_helpers.rb +192 -0
- data/try/pooling/lib/connection_pool_metrics.rb +372 -0
- data/try/pooling/lib/connection_pool_stress_test.rb +959 -0
- data/try/pooling/lib/connection_pool_threading_models.rb +421 -0
- data/try/pooling/lib/visualize_stress_results.rb +434 -0
- data/try/pooling/pool_siege_try.rb +509 -0
- data/try/pooling/run_stress_tests_try.rb +482 -0
- data/try/prototypes/atomic_saves_v1_context_proxy.rb +121 -0
- data/try/prototypes/atomic_saves_v2_connection_switching.rb +161 -0
- data/try/prototypes/atomic_saves_v3_connection_pool.rb +189 -0
- data/try/prototypes/atomic_saves_v4.rb +105 -0
- data/try/prototypes/lib/atomic_saves_v2_connection_switching_helpers.rb +124 -0
- data/try/prototypes/lib/atomic_saves_v3_connection_pool_helpers.rb +192 -0
- metadata +143 -46
- data/.github/workflows/ruby.yml +0 -71
- data/VERSION.yml +0 -4
- data/lib/familia/redistype/commands.rb +0 -59
- data/lib/familia/redistype.rb +0 -228
- data/lib/familia/tools.rb +0 -68
- data/lib/redis_middleware.rb +0 -109
- data/try/20_redis_type_try.rb +0 -70
- data/try/91_json_bug_try.rb +0 -86
@@ -0,0 +1,434 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# try/prototypes/visualize_stress_results.rb
|
3
|
+
#
|
4
|
+
# Simple Visualization Tool for Connection Pool Stress Test Results
|
5
|
+
#
|
6
|
+
# This script reads CSV output from stress tests and generates:
|
7
|
+
# - ASCII charts for terminal display
|
8
|
+
# - Markdown-formatted reports
|
9
|
+
# - Comparison tables
|
10
|
+
# - Performance trend analysis
|
11
|
+
|
12
|
+
require 'csv'
|
13
|
+
require 'optparse'
|
14
|
+
|
15
|
+
class StressTestVisualizer
|
16
|
+
def initialize(csv_files = [])
|
17
|
+
@csv_files = csv_files
|
18
|
+
@data = {}
|
19
|
+
load_data
|
20
|
+
end
|
21
|
+
|
22
|
+
def load_data
|
23
|
+
@csv_files.each do |file|
|
24
|
+
next unless File.exist?(file)
|
25
|
+
|
26
|
+
type = detect_csv_type(file)
|
27
|
+
@data[type] ||= []
|
28
|
+
|
29
|
+
CSV.foreach(file, headers: true) do |row|
|
30
|
+
@data[type] << row.to_h
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def detect_csv_type(filename)
|
36
|
+
case filename
|
37
|
+
when /operations/ then :operations
|
38
|
+
when /errors/ then :errors
|
39
|
+
when /pool_stats/ then :pool_stats
|
40
|
+
when /summary/ then :summary
|
41
|
+
when /comparison/ then :comparison
|
42
|
+
else :unknown
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def generate_report
|
47
|
+
report = []
|
48
|
+
report << "# Connection Pool Stress Test Results"
|
49
|
+
report << "\nGenerated: #{Time.now}"
|
50
|
+
report << "\n"
|
51
|
+
|
52
|
+
# Summary section
|
53
|
+
if @data[:summary]
|
54
|
+
report << "## Summary"
|
55
|
+
report << generate_summary_table
|
56
|
+
report << "\n"
|
57
|
+
end
|
58
|
+
|
59
|
+
# Performance charts
|
60
|
+
if @data[:operations]
|
61
|
+
report << "## Performance Analysis"
|
62
|
+
report << generate_performance_analysis
|
63
|
+
report << "\n"
|
64
|
+
end
|
65
|
+
|
66
|
+
# Pool utilization
|
67
|
+
if @data[:pool_stats]
|
68
|
+
report << "## Pool Utilization"
|
69
|
+
report << generate_pool_utilization_chart
|
70
|
+
report << "\n"
|
71
|
+
end
|
72
|
+
|
73
|
+
# Error analysis
|
74
|
+
if @data[:errors] && @data[:errors].any?
|
75
|
+
report << "## Error Analysis"
|
76
|
+
report << generate_error_analysis
|
77
|
+
report << "\n"
|
78
|
+
end
|
79
|
+
|
80
|
+
# Comparison
|
81
|
+
if @data[:comparison]
|
82
|
+
report << "## Configuration Comparison"
|
83
|
+
report << generate_comparison_table
|
84
|
+
report << "\n"
|
85
|
+
end
|
86
|
+
|
87
|
+
report.join("\n")
|
88
|
+
end
|
89
|
+
|
90
|
+
def generate_summary_table
|
91
|
+
return "No summary data available" unless @data[:summary]
|
92
|
+
|
93
|
+
table = []
|
94
|
+
table << "| Metric | Value |"
|
95
|
+
table << "|--------|-------|"
|
96
|
+
|
97
|
+
@data[:summary].each do |row|
|
98
|
+
metric = row['metric'] || row[:metric]
|
99
|
+
value = row['value'] || row[:value]
|
100
|
+
|
101
|
+
# Format numeric values
|
102
|
+
if value.to_s =~ /^\d+\.?\d*$/
|
103
|
+
value = format_number(value.to_f)
|
104
|
+
end
|
105
|
+
|
106
|
+
table << "| #{metric} | #{value} |"
|
107
|
+
end
|
108
|
+
|
109
|
+
table.join("\n")
|
110
|
+
end
|
111
|
+
|
112
|
+
def generate_performance_analysis
|
113
|
+
return "No operations data available" unless @data[:operations]
|
114
|
+
|
115
|
+
analysis = []
|
116
|
+
|
117
|
+
# Calculate percentiles
|
118
|
+
durations = @data[:operations].map { |op| op['duration'].to_f }.sort
|
119
|
+
|
120
|
+
analysis << "### Response Time Distribution"
|
121
|
+
analysis << "```"
|
122
|
+
analysis << generate_histogram(durations, 20)
|
123
|
+
analysis << "```"
|
124
|
+
|
125
|
+
# Operations over time
|
126
|
+
analysis << "\n### Operations Timeline"
|
127
|
+
analysis << "```"
|
128
|
+
analysis << generate_timeline_chart(@data[:operations])
|
129
|
+
analysis << "```"
|
130
|
+
|
131
|
+
# Success rate by operation type
|
132
|
+
by_type = @data[:operations].group_by { |op| op['type'] }
|
133
|
+
|
134
|
+
analysis << "\n### Success Rate by Operation Type"
|
135
|
+
analysis << "| Type | Total | Success | Rate |"
|
136
|
+
analysis << "|------|-------|---------|------|"
|
137
|
+
|
138
|
+
by_type.each do |type, ops|
|
139
|
+
total = ops.size
|
140
|
+
success = ops.count { |op| op['success'] == 'true' }
|
141
|
+
rate = (success.to_f / total * 100).round(2)
|
142
|
+
|
143
|
+
analysis << "| #{type} | #{total} | #{success} | #{rate}% |"
|
144
|
+
end
|
145
|
+
|
146
|
+
analysis.join("\n")
|
147
|
+
end
|
148
|
+
|
149
|
+
def generate_pool_utilization_chart
|
150
|
+
return "No pool stats available" unless @data[:pool_stats]
|
151
|
+
|
152
|
+
chart = []
|
153
|
+
chart << "```"
|
154
|
+
|
155
|
+
# Get utilization values
|
156
|
+
utils = @data[:pool_stats].map { |stat| stat['utilization'].to_f }
|
157
|
+
|
158
|
+
# Create time-series chart
|
159
|
+
chart_height = 15
|
160
|
+
chart_width = [utils.size, 80].min
|
161
|
+
|
162
|
+
# Sample if too many data points
|
163
|
+
if utils.size > chart_width
|
164
|
+
sample_rate = utils.size / chart_width
|
165
|
+
sampled_utils = []
|
166
|
+
(0...chart_width).each do |i|
|
167
|
+
sampled_utils << utils[i * sample_rate]
|
168
|
+
end
|
169
|
+
utils = sampled_utils
|
170
|
+
end
|
171
|
+
|
172
|
+
# Build chart
|
173
|
+
(0..10).each do |i|
|
174
|
+
level = 100 - (i * 10)
|
175
|
+
line = sprintf("%3d%% |", level)
|
176
|
+
|
177
|
+
utils.each do |util|
|
178
|
+
if util >= level - 5 && util < level + 5
|
179
|
+
line += "●"
|
180
|
+
elsif util >= level
|
181
|
+
line += "│"
|
182
|
+
else
|
183
|
+
line += " "
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
chart << line
|
188
|
+
end
|
189
|
+
|
190
|
+
chart << " 0% |" + "─" * utils.size
|
191
|
+
chart << " " + " " * (utils.size / 2 - 2) + "Time"
|
192
|
+
chart << "```"
|
193
|
+
|
194
|
+
# Add statistics
|
195
|
+
chart << "\n**Pool Utilization Statistics:**"
|
196
|
+
chart << "- Average: #{(utils.sum / utils.size).round(2)}%"
|
197
|
+
chart << "- Maximum: #{utils.max.round(2)}%"
|
198
|
+
chart << "- Minimum: #{utils.min.round(2)}%"
|
199
|
+
|
200
|
+
chart.join("\n")
|
201
|
+
end
|
202
|
+
|
203
|
+
def generate_error_analysis
|
204
|
+
return "No errors recorded" unless @data[:errors] && @data[:errors].any?
|
205
|
+
|
206
|
+
analysis = []
|
207
|
+
|
208
|
+
# Group by error type
|
209
|
+
by_type = @data[:errors].group_by { |err| err['error_type'] }
|
210
|
+
|
211
|
+
analysis << "### Error Distribution"
|
212
|
+
analysis << "| Error Type | Count | Percentage |"
|
213
|
+
analysis << "|------------|-------|------------|"
|
214
|
+
|
215
|
+
total_errors = @data[:errors].size
|
216
|
+
|
217
|
+
by_type.each do |type, errors|
|
218
|
+
count = errors.size
|
219
|
+
percentage = (count.to_f / total_errors * 100).round(2)
|
220
|
+
analysis << "| #{type} | #{count} | #{percentage}% |"
|
221
|
+
end
|
222
|
+
|
223
|
+
# Error timeline
|
224
|
+
analysis << "\n### Error Timeline"
|
225
|
+
analysis << "```"
|
226
|
+
analysis << generate_error_timeline(@data[:errors])
|
227
|
+
analysis << "```"
|
228
|
+
|
229
|
+
analysis.join("\n")
|
230
|
+
end
|
231
|
+
|
232
|
+
def generate_comparison_table
|
233
|
+
return "No comparison data available" unless @data[:comparison]
|
234
|
+
|
235
|
+
table = []
|
236
|
+
table << "### Test Configuration Comparison"
|
237
|
+
table << ""
|
238
|
+
|
239
|
+
# Create comparison table
|
240
|
+
headers = @data[:comparison].first.keys
|
241
|
+
table << "| " + headers.join(" | ") + " |"
|
242
|
+
table << "|" + headers.map { "-" * 10 }.join("|") + "|"
|
243
|
+
|
244
|
+
@data[:comparison].each do |row|
|
245
|
+
values = headers.map do |header|
|
246
|
+
value = row[header]
|
247
|
+
if value.to_s =~ /^\d+\.?\d*$/
|
248
|
+
format_number(value.to_f)
|
249
|
+
else
|
250
|
+
value
|
251
|
+
end
|
252
|
+
end
|
253
|
+
table << "| " + values.join(" | ") + " |"
|
254
|
+
end
|
255
|
+
|
256
|
+
table.join("\n")
|
257
|
+
end
|
258
|
+
|
259
|
+
private
|
260
|
+
|
261
|
+
def generate_histogram(values, bins = 20)
|
262
|
+
return "No data" if values.empty?
|
263
|
+
|
264
|
+
min_val = values.min
|
265
|
+
max_val = values.max
|
266
|
+
range = max_val - min_val
|
267
|
+
bin_width = range / bins.to_f
|
268
|
+
|
269
|
+
# Count values in each bin
|
270
|
+
histogram = Array.new(bins, 0)
|
271
|
+
values.each do |val|
|
272
|
+
bin = ((val - min_val) / bin_width).floor
|
273
|
+
bin = bins - 1 if bin >= bins
|
274
|
+
histogram[bin] += 1
|
275
|
+
end
|
276
|
+
|
277
|
+
# Find max count for scaling
|
278
|
+
max_count = histogram.max
|
279
|
+
chart_width = 50
|
280
|
+
|
281
|
+
# Generate chart
|
282
|
+
chart = []
|
283
|
+
histogram.each_with_index do |count, i|
|
284
|
+
bar_length = (count.to_f / max_count * chart_width).round
|
285
|
+
label = sprintf("%.3f-%.3f", min_val + i * bin_width, min_val + (i + 1) * bin_width)
|
286
|
+
bar = "█" * bar_length
|
287
|
+
chart << sprintf("%-15s |%-#{chart_width}s| %d", label, bar, count)
|
288
|
+
end
|
289
|
+
|
290
|
+
chart.join("\n")
|
291
|
+
end
|
292
|
+
|
293
|
+
def generate_timeline_chart(operations)
|
294
|
+
return "No data" if operations.empty?
|
295
|
+
|
296
|
+
# Group by time buckets
|
297
|
+
start_time = operations.map { |op| op['timestamp'].to_f }.min
|
298
|
+
end_time = operations.map { |op| op['timestamp'].to_f }.max
|
299
|
+
duration = end_time - start_time
|
300
|
+
|
301
|
+
buckets = 40
|
302
|
+
bucket_width = duration / buckets
|
303
|
+
timeline = Array.new(buckets) { { success: 0, failure: 0 } }
|
304
|
+
|
305
|
+
operations.each do |op|
|
306
|
+
bucket = ((op['timestamp'].to_f - start_time) / bucket_width).floor
|
307
|
+
bucket = buckets - 1 if bucket >= buckets
|
308
|
+
|
309
|
+
if op['success'] == 'true'
|
310
|
+
timeline[bucket][:success] += 1
|
311
|
+
else
|
312
|
+
timeline[bucket][:failure] += 1
|
313
|
+
end
|
314
|
+
end
|
315
|
+
|
316
|
+
# Generate chart
|
317
|
+
max_ops = timeline.map { |b| b[:success] + b[:failure] }.max
|
318
|
+
chart_height = 10
|
319
|
+
|
320
|
+
chart = []
|
321
|
+
chart_height.downto(0) do |level|
|
322
|
+
line = ""
|
323
|
+
timeline.each do |bucket|
|
324
|
+
total = bucket[:success] + bucket[:failure]
|
325
|
+
if total >= (level.to_f / chart_height * max_ops)
|
326
|
+
if bucket[:failure] > 0
|
327
|
+
line += "✗"
|
328
|
+
else
|
329
|
+
line += "●"
|
330
|
+
end
|
331
|
+
else
|
332
|
+
line += " "
|
333
|
+
end
|
334
|
+
end
|
335
|
+
chart << line
|
336
|
+
end
|
337
|
+
|
338
|
+
chart << "─" * buckets
|
339
|
+
chart << "0" + " " * (buckets / 2 - 3) + "Time (s)" + " " * (buckets / 2 - 5) + sprintf("%.1f", duration)
|
340
|
+
|
341
|
+
chart.join("\n")
|
342
|
+
end
|
343
|
+
|
344
|
+
def generate_error_timeline(errors)
|
345
|
+
return "No errors" if errors.empty?
|
346
|
+
|
347
|
+
# Group errors by time
|
348
|
+
start_time = errors.map { |e| e['timestamp'].to_f }.min
|
349
|
+
end_time = errors.map { |e| e['timestamp'].to_f }.max
|
350
|
+
duration = end_time - start_time
|
351
|
+
|
352
|
+
buckets = 60
|
353
|
+
bucket_width = duration / buckets
|
354
|
+
timeline = Array.new(buckets, 0)
|
355
|
+
|
356
|
+
errors.each do |error|
|
357
|
+
bucket = ((error['timestamp'].to_f - start_time) / bucket_width).floor
|
358
|
+
bucket = buckets - 1 if bucket >= buckets
|
359
|
+
timeline[bucket] += 1
|
360
|
+
end
|
361
|
+
|
362
|
+
# Generate sparkline
|
363
|
+
max_errors = timeline.max
|
364
|
+
sparkline = timeline.map do |count|
|
365
|
+
if count == 0
|
366
|
+
"▁"
|
367
|
+
elsif count <= max_errors * 0.25
|
368
|
+
"▂"
|
369
|
+
elsif count <= max_errors * 0.5
|
370
|
+
"▄"
|
371
|
+
elsif count <= max_errors * 0.75
|
372
|
+
"▆"
|
373
|
+
else
|
374
|
+
"█"
|
375
|
+
end
|
376
|
+
end.join
|
377
|
+
|
378
|
+
"Error frequency: #{sparkline}"
|
379
|
+
end
|
380
|
+
|
381
|
+
def format_number(num)
|
382
|
+
if num < 0.001
|
383
|
+
sprintf("%.6f", num)
|
384
|
+
elsif num < 1
|
385
|
+
sprintf("%.4f", num)
|
386
|
+
elsif num < 100
|
387
|
+
sprintf("%.2f", num)
|
388
|
+
else
|
389
|
+
num.round.to_s
|
390
|
+
end
|
391
|
+
end
|
392
|
+
end
|
393
|
+
|
394
|
+
# Command-line interface
|
395
|
+
if __FILE__ == $0
|
396
|
+
options = {}
|
397
|
+
|
398
|
+
OptionParser.new do |opts|
|
399
|
+
opts.banner = "Usage: visualize_stress_results.rb [options] file1.csv file2.csv ..."
|
400
|
+
|
401
|
+
opts.on("-o", "--output FILE", "Output file (default: stdout)") do |file|
|
402
|
+
options[:output] = file
|
403
|
+
end
|
404
|
+
|
405
|
+
opts.on("-f", "--format FORMAT", "Output format: markdown, text (default: markdown)") do |format|
|
406
|
+
options[:format] = format
|
407
|
+
end
|
408
|
+
|
409
|
+
opts.on("-h", "--help", "Show this help") do
|
410
|
+
puts opts
|
411
|
+
exit
|
412
|
+
end
|
413
|
+
end.parse!
|
414
|
+
|
415
|
+
# Get CSV files
|
416
|
+
csv_files = ARGV.empty? ? Dir.glob("*.csv") : ARGV
|
417
|
+
|
418
|
+
if csv_files.empty?
|
419
|
+
puts "No CSV files found. Please specify files or run in a directory with CSV files."
|
420
|
+
exit 1
|
421
|
+
end
|
422
|
+
|
423
|
+
# Generate visualization
|
424
|
+
visualizer = StressTestVisualizer.new(csv_files)
|
425
|
+
report = visualizer.generate_report
|
426
|
+
|
427
|
+
# Output
|
428
|
+
if options[:output]
|
429
|
+
File.write(options[:output], report)
|
430
|
+
puts "Report written to: #{options[:output]}"
|
431
|
+
else
|
432
|
+
puts report
|
433
|
+
end
|
434
|
+
end
|