cronv-rb 0.2.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,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CronvRb
4
+ class Schedule
5
+ attr_reader :minutes, :hour, :day_of_month, :month, :day_of_week, :year, :schedule_alias
6
+
7
+ # rubocop:disable Metrics/ParameterLists
8
+ def initialize(minutes:, hour:, day_of_month:, month:, day_of_week:, year:, schedule_alias:)
9
+ @minutes = minutes
10
+ @hour = hour
11
+ @day_of_month = day_of_month
12
+ @month = month
13
+ @day_of_week = day_of_week
14
+ @year = year
15
+ @schedule_alias = schedule_alias
16
+ end
17
+ # rubocop:enable Metrics/ParameterLists
18
+
19
+ def to_crontab
20
+ return @schedule_alias if @schedule_alias
21
+
22
+ "#{@minutes} #{@hour} #{@day_of_month} #{@month} #{@day_of_week} #{@year}"
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'time'
5
+
6
+ module CronvRb
7
+ # rubocop:disable Metrics/ClassLength
8
+ class Template
9
+ TEMPLATE = <<~ERB
10
+ <html>
11
+ <head>
12
+ <title><%= @v.option.title %> | <%= date_format(value: @v.time_from, format: "%Y/%-m/%-d %H:%M") %>, +<%= @v.option.duration %></title>
13
+ <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
14
+ </head>
15
+ <body>
16
+ <div class="container-fluid">
17
+ <h1>
18
+ <%= @v.option.title %>&nbsp;<small class="text-muted">From <%= date_format(value: @v.time_from, format: "%Y/%-m/%-d %H:%M") %>, +<%= @v.option.duration %></small>
19
+ </h1>
20
+
21
+ <br>
22
+
23
+ <% if @v.extras.any? %>
24
+ <h3>Extra</h3>
25
+ <div id="cronv-extra" style="width:<%= @v.option.width %>%;">
26
+ <dl class="row">
27
+ <% @v.extras.each do |extra| %>
28
+ <dt class="col-sm-1"><%= extra.label %></dt>
29
+ <dd class="col-sm-11"><%= extra.job %></dd>
30
+ <% end %>
31
+ </dl>
32
+ </div>
33
+ <hr>
34
+ <% end %>
35
+
36
+ <h3>Timeline</h3>
37
+ <div id="cronv-timeline" style="height:100%; width:<%= @v.option.width %>%;">
38
+ <b>Loading...</b>
39
+ </div>
40
+ </div>
41
+ <script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
42
+ <script type="text/javascript">
43
+ google.charts.load("current", {packages:["timeline"]});
44
+ google.charts.setOnLoadCallback(function() {
45
+ var container = document.getElementById('cronv-timeline');
46
+ var chart = new google.visualization.Timeline(container);
47
+ var dataTable = new google.visualization.DataTable();
48
+ dataTable.addColumn({ type: 'string', id: 'job' });
49
+ dataTable.addColumn({ type: 'string', id: 'dummy bar label' });
50
+ dataTable.addColumn({ type: 'string', role: 'tooltip' });
51
+ dataTable.addColumn({ type: 'date', id: 'Start' });
52
+ dataTable.addColumn({ type: 'date', id: 'End' });
53
+
54
+ var tasks = {};
55
+ <%# time_from = @v.time_from %>
56
+ <%# time_to = @v.time_to %>
57
+ <% @v.records.each do |cronv| %>
58
+ <% job = js_escape_string(str: cronv.crontab.job) %>
59
+ tasks['<%= job %>'] = tasks['<%= job %>'] || [];
60
+ <% if running_every_minutes?(crontab: cronv.crontab) %>
61
+ tasks['<%= job %>'].push(['<%= job %>', '', 'Every minutes <%= job %>', <%= new_js_date(value: @v.time_from) %>, <%= new_js_date(value: @v.time_to) %>]);
62
+ <% else %>
63
+ <% cronv_iter(cronv:).each do |exec| %>tasks['<%= job %>'].push(['<%= job %>', '', '<%= date_format(value: exec[:start], format: "%H:%M") %> <%= job %>', <%= new_js_date(value: exec[:start]) %>, <%= new_js_date(value: exec[:end]) %>]);<% end %>
64
+ <% end %>
65
+ <% end %>
66
+
67
+ var taskByJobCount = [];
68
+ for (var k in tasks) taskByJobCount.push({name: k, size: tasks[k].length});
69
+ taskByJobCount.sort(function(a, b) {
70
+ if (a.size == b.size) return 0;
71
+ return a.size > b.size ? -1 : 1;
72
+ });
73
+
74
+ var rows = [];
75
+ for (var i = 0; i < taskByJobCount.length; i++) {
76
+ jobs = tasks[taskByJobCount[i].name];
77
+ var jl = jobs.length;
78
+ for (var j = 0; j < jl; j++) rows.push(jobs[j]);
79
+ }
80
+
81
+ if (rows.length > 0) {
82
+ dataTable.addRows(rows);
83
+ chart.draw(dataTable, {
84
+ timeline: {
85
+ colorByRowLabel: true
86
+ },
87
+ avoidOverlappingGridLines: false
88
+ });
89
+ } else {
90
+ container.innerHTML = '<div class="alert alert-success"><strong>Woops!</strong> There is no data!</div>';
91
+ }
92
+
93
+ var mousePosX = undefined,
94
+ mousePosY = undefined;
95
+
96
+ google.visualization.events.addListener(chart, 'onmouseover', function(e) {
97
+ var t = document.getElementsByClassName("google-visualization-tooltip")[0];
98
+ if (mousePosX) t.style.left = mousePosX + 'px';
99
+ if (mousePosY) t.style.top = mousePosY - 120 + 'px';
100
+ });
101
+
102
+ document.addEventListener('mousemove', function(e) {
103
+ mousePosX = e.pageX;
104
+ mousePosY = e.pageY;
105
+ });
106
+ });
107
+
108
+ </script>
109
+ </body>
110
+ </html>
111
+ ERB
112
+
113
+ def self.render(visualizer:)
114
+ new(visualizer:).render
115
+ end
116
+
117
+ def render
118
+ ERB.new(TEMPLATE).result(binding)
119
+ end
120
+
121
+ private_class_method :new
122
+
123
+ private
124
+
125
+ def initialize(visualizer:)
126
+ @v = visualizer
127
+ end
128
+
129
+ def cronv_iter(cronv:)
130
+ cronv.iter
131
+ end
132
+
133
+ def js_escape_string(str:)
134
+ str.gsub(/['"\\]/) { |char| "\\#{char}" }.gsub("\n", '\\n').gsub("\r", '\\r')
135
+ end
136
+
137
+ def new_js_date(value:)
138
+ time = value.is_a?(String) ? Time.parse(value) : value
139
+ format('new Date(%<year>d, %<month>d, %<day>d, %<hour>d, %<min>d)', year: time.year, month: time.month - 1, day: time.day, hour: time.hour, min: time.min)
140
+ end
141
+
142
+ def date_format(value:, format:)
143
+ time = value.is_a?(String) ? Time.parse(value) : value
144
+ time.strftime(format)
145
+ end
146
+
147
+ def running_every_minutes?(crontab:)
148
+ crontab.running_every_minutes?(crontab.schedule)
149
+ end
150
+ end
151
+ # rubocop:enable Metrics/ClassLength
152
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CronvRb
4
+ VERSION = '0.2.0'
5
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'time'
5
+
6
+ module CronvRb
7
+ class Visualizer
8
+ attr_reader :option, :time_from, :time_to, :duration_minutes, :records, :extras
9
+
10
+ class << self
11
+ def new_visualizer(option)
12
+ time_from = option.to_from_time
13
+ duration_minutes = option.to_duration_minutes
14
+ time_to = time_from + duration_minutes * 60
15
+
16
+ new(option, time_from, time_to, duration_minutes)
17
+ end
18
+ end
19
+
20
+ def add(line)
21
+ trimmed = line.strip
22
+
23
+ return [false, nil] if trimmed.empty? || trimmed.start_with?('#')
24
+
25
+ begin
26
+ record, extra = Record.new_record(line: trimmed, start_time: @time_from, duration_minutes: @duration_minutes)
27
+ rescue ArgumentError => e
28
+ warn "[WARN] Skipping invalid crontab line: '#{trimmed}' (#{e.message})"
29
+ return [false, nil]
30
+ end
31
+
32
+ @records.push(record) unless record.nil?
33
+ @extras.push(extra) unless extra.nil?
34
+
35
+ [true, nil]
36
+ end
37
+
38
+ def dump
39
+ File.write(@option.output_file_path, Template.render(visualizer: self))
40
+ @option.output_file_path
41
+ end
42
+
43
+ private
44
+
45
+ def initialize(option, time_from, time_to, duration_minutes)
46
+ @option = option
47
+ @time_from = time_from
48
+ @time_to = time_to
49
+ @duration_minutes = duration_minutes
50
+ @records = []
51
+ @extras = []
52
+ end
53
+ end
54
+ end
data/lib/cronv_rb.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir[File.join(__dir__, 'cronv_rb', '*.rb')].each do |file|
4
+ require "cronv_rb/#{File.basename(file, '.rb')}"
5
+ end
6
+
7
+ ENV['TZ'] = 'UTC'
8
+
9
+ module CronvRb
10
+ class Error < StandardError; end
11
+ # Your code goes here...
12
+ end
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
3
+ "packages": {
4
+ ".": {
5
+ "release-type": "ruby",
6
+ "version-file": "lib/cronv_rb/version.rb",
7
+ "bump-minor-pre-major": true,
8
+ "bump-patch-for-minor-pre-major": false,
9
+ "include-component-in-tag": false,
10
+ "changelog-sections": [
11
+ { "type": "feat", "section": "Features" },
12
+ { "type": "fix", "section": "Bug Fixes" },
13
+ { "type": "perf", "section": "Performance Improvements" },
14
+ { "type": "docs", "section": "Documentation", "hidden": true },
15
+ { "type": "refactor", "section": "Code Refactoring", "hidden": true },
16
+ { "type": "chore", "section": "Miscellaneous", "hidden": true }
17
+ ]
18
+ }
19
+ }
20
+ }
data/renovate.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
+ "extends": ["config:recommended"],
4
+ "semanticCommits": "enabled",
5
+ "semanticCommitType": "fix",
6
+ "semanticCommitScope": "deps",
7
+ "labels": ["dependencies"],
8
+ "addLabels": ["renovate"],
9
+ "schedule": ["every weekend"],
10
+ "prConcurrentLimit": 5,
11
+ "platformAutomerge": false,
12
+ "osvVulnerabilityAlerts": true,
13
+ "vulnerabilityAlerts": {
14
+ "enabled": true,
15
+ "labels": ["dependencies", "security"],
16
+ "automerge": true,
17
+ "schedule": ["at any time"]
18
+ },
19
+ "packageRules": [
20
+ {
21
+ "description": "Terraform (github-repo-management) owns these distributed workflow files; Renovate must not edit them or it fights Terraform over the same content.",
22
+ "matchFileNames": [
23
+ ".github/workflows/ai-triage.yml",
24
+ ".github/workflows/ai-fix.yml",
25
+ ".github/workflows/ai-review.yml",
26
+ ".github/workflows/auto-merge.yml",
27
+ ".github/workflows/codeql.yml",
28
+ ".github/workflows/digest.yml",
29
+ ".github/workflows/drift.yml"
30
+ ],
31
+ "enabled": false
32
+ },
33
+ {
34
+ "matchUpdateTypes": ["patch", "minor"],
35
+ "automerge": true
36
+ },
37
+ {
38
+ "matchUpdateTypes": ["major"],
39
+ "automerge": false,
40
+ "labels": ["dependencies", "major-update"]
41
+ }
42
+ ]
43
+ }
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Compares the HTML outputs of OSS cronv (Go) and cronv-rb (Ruby) to verify
5
+ # that job names and color/time ranges match.
6
+ #
7
+ # Usage:
8
+ # ruby scripts/compare_cronv_outputs.rb <oss_cronv.html> <cronvrb.html>
9
+
10
+ def usage
11
+ "Usage: #{$PROGRAM_NAME} <oss_cronv.html> <cronvrb.html>"
12
+ end
13
+
14
+ abort usage if ARGV.length != 2
15
+
16
+ oss_path, rb_path = ARGV
17
+
18
+ abort "File not found: #{oss_path}" unless File.exist?(oss_path)
19
+ abort "File not found: #{rb_path}" unless File.exist?(rb_path)
20
+
21
+ # Parse the "Extra" section (e.g. @reboot entries) from the HTML.
22
+ # Returns an array of { label:, job: } hashes.
23
+ def parse_extras(html)
24
+ extras = []
25
+ html.scan(%r{<dt[^>]*>(.*?)</dt>\s*<dd[^>]*>(.*?)</dd>}m) do |label, command|
26
+ extras << { label: label.strip, job: command.strip }
27
+ end
28
+ extras
29
+ end
30
+
31
+ # Normalize a new Date(...) string by removing all spaces after commas/parens
32
+ # so that "new Date(2025, 0, 1, 0, 5)" == "new Date(2025,0,1,0,5)".
33
+ def normalize_date(str)
34
+ str.gsub(/\s+/, '')
35
+ end
36
+
37
+ # Parse all job names that are initialized in the JS block (tasks['job'] = ...).
38
+ # Returns a Set of job name strings.
39
+ def parse_all_job_names(html)
40
+ jobs = Set.new
41
+ html.scan(/tasks\['(.+?)'\]\s*=\s*tasks\[/) { |m| jobs << m[0] }
42
+ jobs
43
+ end
44
+
45
+ # Parse timeline task entries from the JS block in the HTML.
46
+ # Returns a hash: { job_name => [{start:, end:}, ...] }
47
+ def parse_timeline(html)
48
+ timeline = Hash.new { |h, k| h[k] = [] }
49
+
50
+ html.scan(/tasks\['(.+?)'\]\.push\(\['(?:.+?)', '', '(?:.+?)', (new Date\(.+?\)), (new Date\(.+?\))\]\);/) do |job, start_date, end_date|
51
+ timeline[job] << { start: normalize_date(start_date), end: normalize_date(end_date) }
52
+ end
53
+
54
+ timeline
55
+ end
56
+
57
+ puts 'Comparing:'
58
+ puts " OSS cronv : #{oss_path}"
59
+ puts " cronv-rb : #{rb_path}"
60
+ puts
61
+
62
+ oss_html = File.read(oss_path)
63
+ rb_html = File.read(rb_path)
64
+
65
+ errors = []
66
+
67
+ # ── 1. Compare Extras (e.g. @reboot) ─────────────────────────────────────────
68
+ oss_extras = parse_extras(oss_html)
69
+ rb_extras = parse_extras(rb_html)
70
+
71
+ if oss_extras != rb_extras
72
+ errors << 'Extra section mismatch:'
73
+ errors << " OSS cronv : #{oss_extras.inspect}"
74
+ errors << " cronv-rb : #{rb_extras.inspect}"
75
+ else
76
+ puts "[OK] Extras match (#{oss_extras.size} entries)"
77
+ end
78
+
79
+ # ── 2. Compare full job name set (including jobs with no executions) ───────────
80
+ oss_all_jobs = parse_all_job_names(oss_html)
81
+ rb_all_jobs = parse_all_job_names(rb_html)
82
+
83
+ only_in_oss_all = oss_all_jobs - rb_all_jobs
84
+ only_in_rb_all = rb_all_jobs - oss_all_jobs
85
+
86
+ if only_in_oss_all.any?
87
+ errors << 'Jobs present in OSS cronv but missing from cronv-rb:'
88
+ only_in_oss_all.sort.each { |j| errors << " - #{j}" }
89
+ end
90
+
91
+ if only_in_rb_all.any?
92
+ errors << 'Jobs present in cronv-rb but missing from OSS cronv:'
93
+ only_in_rb_all.sort.each { |j| errors << " - #{j}" }
94
+ end
95
+
96
+ puts "[OK] Job names match (#{oss_all_jobs.size} jobs)" if only_in_oss_all.empty? && only_in_rb_all.empty?
97
+
98
+ # ── 3. Compare time ranges for jobs that have executions ─────────────────────
99
+ oss_timeline = parse_timeline(oss_html)
100
+ rb_timeline = parse_timeline(rb_html)
101
+
102
+ common_jobs = oss_all_jobs & rb_all_jobs
103
+ range_errors = []
104
+
105
+ common_jobs.sort.each do |job|
106
+ oss_entries = oss_timeline[job].sort_by { |e| e[:start] }
107
+ rb_entries = rb_timeline[job].sort_by { |e| e[:start] }
108
+
109
+ next if oss_entries == rb_entries
110
+
111
+ range_errors << " Job: #{job}"
112
+ range_errors << " OSS cronv count : #{oss_entries.size}"
113
+ range_errors << " cronv-rb count : #{rb_entries.size}"
114
+
115
+ # Show up to 3 differing entries
116
+ diffs = oss_entries.zip(rb_entries).reject { |a, b| a == b }.first(3)
117
+ diffs.each_with_index do |(oss_e, rb_e), i|
118
+ range_errors << " Diff ##{i + 1}:"
119
+ range_errors << " OSS: #{oss_e.inspect}"
120
+ range_errors << " RB : #{rb_e.inspect}"
121
+ end
122
+ end
123
+
124
+ if range_errors.empty?
125
+ puts "[OK] Time ranges match for all #{common_jobs.size} jobs"
126
+ else
127
+ errors << 'Time range mismatches:'
128
+ errors.concat(range_errors)
129
+ end
130
+
131
+ # ── Result ────────────────────────────────────────────────────────────────────
132
+ puts
133
+ if errors.empty?
134
+ puts 'All checks passed. cronv-rb output matches OSS cronv.'
135
+ exit 0
136
+ else
137
+ puts 'FAILED: Differences found between OSS cronv and cronv-rb:'
138
+ errors.each { |e| puts e }
139
+ exit 1
140
+ end
metadata ADDED
@@ -0,0 +1,125 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cronv-rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Masato Yamashita
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bundler
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.5'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '2.5'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.4'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.4'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rspec
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.0'
54
+ description: A gem that rewrites cronv, originally written in Go, in Ruby to visualize
55
+ your cron schedules in crontab.
56
+ email:
57
+ - 4653960+M-Yamashita01@users.noreply.github.com
58
+ executables:
59
+ - cronv
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - ".github/workflows/ai-fix.yml"
64
+ - ".github/workflows/ai-review.yml"
65
+ - ".github/workflows/ai-triage.yml"
66
+ - ".github/workflows/auto-merge.yml"
67
+ - ".github/workflows/codeql.yml"
68
+ - ".github/workflows/compare_cronv_outputs.yml"
69
+ - ".github/workflows/release-please.yml"
70
+ - ".github/workflows/rspec.yml"
71
+ - ".github/workflows/rubocop.yml"
72
+ - ".gitignore"
73
+ - ".release-please-manifest.json"
74
+ - ".rspec"
75
+ - ".rubocop.yml"
76
+ - ".ruby-version"
77
+ - CHANGELOG.md
78
+ - Gemfile
79
+ - LICENSE
80
+ - README.md
81
+ - Rakefile
82
+ - bin/console
83
+ - bin/setup
84
+ - cronv-rb.gemspec
85
+ - docs/release.md
86
+ - exe/cronv
87
+ - fixtures/sample.crontab
88
+ - lib/cronv_rb.rb
89
+ - lib/cronv_rb/crontab.rb
90
+ - lib/cronv_rb/logger.rb
91
+ - lib/cronv_rb/main.rb
92
+ - lib/cronv_rb/option.rb
93
+ - lib/cronv_rb/record.rb
94
+ - lib/cronv_rb/schedule.rb
95
+ - lib/cronv_rb/template.rb
96
+ - lib/cronv_rb/version.rb
97
+ - lib/cronv_rb/visualizer.rb
98
+ - release-please-config.json
99
+ - renovate.json
100
+ - scripts/compare_cronv_outputs.rb
101
+ homepage: https://github.com/M-Yamashita01/cronv-rb
102
+ licenses:
103
+ - MIT
104
+ metadata:
105
+ homepage_uri: https://github.com/M-Yamashita01/cronv-rb
106
+ source_code_uri: https://github.com/M-Yamashita01/cronv-rb
107
+ changelog_uri: https://github.com/M-Yamashita01/cronv-rb
108
+ rdoc_options: []
109
+ require_paths:
110
+ - lib
111
+ required_ruby_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '3.2'
116
+ required_rubygems_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '0'
121
+ requirements: []
122
+ rubygems_version: 4.0.20
123
+ specification_version: 4
124
+ summary: Visualize your cron schedules in crontab by Ruby
125
+ test_files: []