side_bro 0.2.2

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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +37 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +50 -0
  5. data/Rakefile +10 -0
  6. data/lib/side_bro/version.rb +5 -0
  7. data/lib/side_bro/web/action.rb +115 -0
  8. data/lib/side_bro/web/application.rb +331 -0
  9. data/lib/side_bro/web/helpers.rb +176 -0
  10. data/lib/side_bro/web/router.rb +50 -0
  11. data/lib/side_bro/web.rb +96 -0
  12. data/lib/side_bro.rb +8 -0
  13. data/sig/side_bro.rbs +4 -0
  14. data/web/assets/images/.keep +0 -0
  15. data/web/assets/javascripts/application.js +62 -0
  16. data/web/assets/javascripts/base-charts.js +1 -0
  17. data/web/assets/javascripts/dashboard-charts.js +1 -0
  18. data/web/assets/javascripts/dashboard.js +262 -0
  19. data/web/assets/javascripts/metrics.js +1 -0
  20. data/web/assets/stylesheets/style.css +636 -0
  21. data/web/locales/en.yml +88 -0
  22. data/web/views/_footer.html.erb +3 -0
  23. data/web/views/_job_info.html.erb +23 -0
  24. data/web/views/_metrics_period_select.html.erb +5 -0
  25. data/web/views/_nav.html.erb +76 -0
  26. data/web/views/_paging.html.erb +11 -0
  27. data/web/views/_poll_link.html.erb +4 -0
  28. data/web/views/_summary.html.erb +44 -0
  29. data/web/views/busy.html.erb +104 -0
  30. data/web/views/dashboard.html.erb +124 -0
  31. data/web/views/dead.html.erb +31 -0
  32. data/web/views/layout.html.erb +61 -0
  33. data/web/views/metrics.html.erb +56 -0
  34. data/web/views/metrics_for_job.html.erb +67 -0
  35. data/web/views/morgue.html.erb +82 -0
  36. data/web/views/queue.html.erb +190 -0
  37. data/web/views/queues.html.erb +59 -0
  38. data/web/views/retries.html.erb +99 -0
  39. data/web/views/retry.html.erb +32 -0
  40. data/web/views/scheduled.html.erb +79 -0
  41. data/web/views/scheduled_job_info.html.erb +31 -0
  42. metadata +126 -0
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "cgi"
5
+
6
+ module SideBro
7
+ module WebHelpers
8
+ QUEUE_NAME_RE = /\A[a-z_:.\-0-9]+\z/i
9
+
10
+ # Builds a query string from current request params merged with overrides.
11
+ # Pass nil as a value to remove that key from the result.
12
+ def query_string(overrides = {})
13
+ overrides = overrides.transform_keys(&:to_s)
14
+ base = request.params.reject { |k, _| overrides.key?(k.to_s) }
15
+ merged = base.merge(overrides.compact)
16
+ merged.map { |k, v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.join("&")
17
+ end
18
+
19
+ def validate_queue_name!(name)
20
+ halt(404) unless name&.match?(QUEUE_NAME_RE)
21
+ end
22
+
23
+ def current_path?(path)
24
+ request.path == "#{root_path}#{path}".chomp("/")
25
+ end
26
+
27
+ def root_path
28
+ # Strips trailing slash from SCRIPT_NAME so prefix is always clean
29
+ script = request.env["SCRIPT_NAME"] || ""
30
+ script.end_with?("/") ? script : "#{script}/"
31
+ end
32
+
33
+ def locale
34
+ session[:locale] || "en"
35
+ end
36
+
37
+ def t(key, **opts)
38
+ str = SideBro::Web.translations.dig(locale.to_s, key.to_s) ||
39
+ SideBro::Web.translations.dig("en", key.to_s) ||
40
+ key.to_s
41
+ return str if opts.empty?
42
+ begin
43
+ str % opts
44
+ rescue
45
+ str
46
+ end
47
+ end
48
+
49
+ def relative_time(time)
50
+ return "" unless time
51
+ secs = (Time.now - time).to_i.abs
52
+ if secs < 60 then "#{secs}s"
53
+ elsif secs < 3600 then "#{secs / 60}m"
54
+ elsif secs < 86400 then "#{secs / 3600}h"
55
+ else "#{secs / 86400}d"
56
+ end
57
+ end
58
+
59
+ def format_memory(kb)
60
+ return "–" unless kb
61
+ mb = kb / 1024.0
62
+ mb >= 1024 ? "%.1f GB" % (mb / 1024.0) : "%.0f MB" % mb
63
+ end
64
+
65
+ def truncate(str, len = 200)
66
+ return str if str.nil? || str.length <= len
67
+ "#{str[0, len]}…"
68
+ end
69
+
70
+ ACTIVEJOB_WRAPPER = "ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper"
71
+
72
+ def job_display_class(job_hash)
73
+ return job_hash["class"] unless job_hash["class"] == ACTIVEJOB_WRAPPER
74
+ inner = (job_hash["args"] || []).first
75
+ inner.is_a?(Hash) ? (inner["job_class"] || ACTIVEJOB_WRAPPER) : ACTIVEJOB_WRAPPER
76
+ end
77
+
78
+ def raw_args(job_hash)
79
+ if job_hash["class"] == ACTIVEJOB_WRAPPER
80
+ inner = (job_hash["args"] || []).first
81
+ inner.is_a?(Hash) ? clean_aj_args(inner["arguments"] || []) : []
82
+ else
83
+ job_hash["args"] || []
84
+ end
85
+ end
86
+
87
+ def display_args(job_hash)
88
+ truncate(JSON.generate(raw_args(job_hash)))
89
+ rescue
90
+ "–"
91
+ end
92
+
93
+ def format_args_short(job_hash, per_row: 2)
94
+ raw = raw_args(job_hash)
95
+ if raw.length == 1 && raw.first.is_a?(Hash)
96
+ pairs = raw.first.map { |k, v|
97
+ val = v.is_a?(String) ? v : JSON.generate(v)
98
+ "#{k}: #{truncate(val, 20)}"
99
+ }
100
+ pairs.each_slice(per_row).map { |s| s.join(" ") }.join("\n")
101
+ else
102
+ truncate(JSON.generate(raw), 60)
103
+ end
104
+ rescue
105
+ "–"
106
+ end
107
+
108
+ def clean_aj_args(obj)
109
+ case obj
110
+ when Array then obj.map { |v| clean_aj_args(v) }
111
+ when Hash
112
+ cleaned = obj.reject { |k, _| k.to_s.start_with?("_aj_") }
113
+ .transform_values { |v| clean_aj_args(v) }
114
+ cleaned.size == 1 ? cleaned.values.first : cleaned
115
+ else obj
116
+ end
117
+ end
118
+
119
+ def paginate(set, page, per_page = 25)
120
+ total = set.size
121
+ items = set.map { |j| j }.slice(page * per_page, per_page) || []
122
+ [items, total]
123
+ end
124
+
125
+ def current_page
126
+ page = [(params["page"] || 1).to_i - 1, 0].max
127
+ per_page = (params["per_page"] || 25).to_i.clamp(1, 250)
128
+ [page, per_page]
129
+ end
130
+
131
+ def page_slice
132
+ page, per_page = current_page
133
+ [page * per_page, per_page]
134
+ end
135
+
136
+ def rtl?
137
+ %w[ar fa he ur].include?(locale)
138
+ end
139
+
140
+ def h(text)
141
+ text.to_s
142
+ .gsub("&", "&amp;")
143
+ .gsub("<", "&lt;")
144
+ .gsub(">", "&gt;")
145
+ .gsub('"', "&quot;")
146
+ .gsub("'", "&#39;")
147
+ end
148
+
149
+ def csrf_token
150
+ session[:csrf] ||= SecureRandom.hex(16)
151
+ end
152
+
153
+ def number_with_delimiter(n)
154
+ n.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse
155
+ end
156
+
157
+ def metrics_enabled?
158
+ Gem::Version.new(Sidekiq::VERSION) >= Gem::Version.new("7.0")
159
+ end
160
+
161
+ def handle_job_action(set)
162
+ action_name = params["action"]
163
+ keys = Array(params["key"])
164
+ keys.each do |jid|
165
+ job = set.find { |j| j.jid == jid }
166
+ next unless job
167
+ case action_name
168
+ when "retry" then job.retry
169
+ when "delete" then job.delete
170
+ when "kill" then job.kill if job.respond_to?(:kill)
171
+ when "add_to_queue" then job.add_to_queue if job.respond_to?(:add_to_queue)
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SideBro
4
+ class Web
5
+ class Router
6
+ ROUTE_PARAMS = "rack.route_params"
7
+
8
+ def initialize
9
+ @routes = Hash.new { |h, k| h[k] = [] }
10
+ end
11
+
12
+ def add(method, path, &block)
13
+ pattern, keys = compile(path)
14
+ @routes[method.upcase] << [pattern, keys, block]
15
+ end
16
+
17
+ def get(path, &block) = add("GET", path, &block)
18
+ def post(path, &block) = add("POST", path, &block)
19
+ def head(path, &block) = add("HEAD", path, &block)
20
+
21
+ def match(env)
22
+ method = env["REQUEST_METHOD"]
23
+ path = env["PATH_INFO"]
24
+
25
+ routes_for(method).each do |pattern, keys, block|
26
+ next unless (m = pattern.match(path))
27
+ env[ROUTE_PARAMS] = keys.each_with_object({}) { |k, h| h[k] = m[k] }
28
+ return block
29
+ end
30
+ nil
31
+ end
32
+
33
+ private
34
+
35
+ def routes_for(method)
36
+ list = @routes[method] || []
37
+ # HEAD falls back to GET routes
38
+ (method == "HEAD") ? list + (@routes["GET"] || []) : list
39
+ end
40
+
41
+ def compile(path)
42
+ keys = []
43
+ pattern = path
44
+ .gsub(%r{/\*}) { keys << "splat"; "(?<splat>.*)" }
45
+ .gsub(%r{/:([^/]+)}) { keys << $1; "/(?<#{$1}>[^$/]+)" }
46
+ [Regexp.new("\\A#{pattern}\\z"), keys]
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "rack/session"
5
+ require "securerandom"
6
+ require "yaml"
7
+
8
+ require_relative "web/router"
9
+ require_relative "web/helpers"
10
+ require_relative "web/action"
11
+ require_relative "web/application"
12
+
13
+ module SideBro
14
+ class Web
15
+ ASSETS_PATH = File.expand_path("../../web/assets", __dir__)
16
+
17
+ @middlewares = []
18
+ @translations = {}
19
+
20
+ class << self
21
+ attr_reader :translations
22
+
23
+ def call(env)
24
+ @inst ||= build
25
+ @inst.call(env)
26
+ end
27
+
28
+ def use(middleware, *args, &block)
29
+ @middlewares << [middleware, args, block]
30
+ @inst = nil # reset cached instance
31
+ end
32
+
33
+ def reset!
34
+ @inst = nil
35
+ end
36
+
37
+ def register_extension(extclass, name:, tab: nil, index: nil, root_dir: nil, asset_paths: nil, cache_for: 86400)
38
+ @extensions ||= []
39
+ @extensions << {
40
+ class: extclass,
41
+ name: name,
42
+ tab: tab,
43
+ index: index,
44
+ root_dir: root_dir,
45
+ asset_paths: asset_paths,
46
+ cache_for: cache_for
47
+ }
48
+ @inst = nil # reset cached instance
49
+ end
50
+
51
+ def extensions
52
+ @extensions || []
53
+ end
54
+
55
+ def load_locale(path)
56
+ data = YAML.safe_load_file(path, permitted_classes: [])
57
+ data.each do |lang, keys|
58
+ @translations[lang.to_s] ||= {}
59
+ @translations[lang.to_s].merge!(keys || {})
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def build
66
+ unless ENV.key?("SIDE_BRO_SESSION_SECRET")
67
+ warn "[SideBro] SIDE_BRO_SESSION_SECRET is not set — sessions will reset on every server restart."
68
+ end
69
+
70
+ SideBro::Web.extensions.each do |ext|
71
+ next unless ext[:root_dir]
72
+ Dir["#{ext[:root_dir]}/locales/*.yml"].each { |f| SideBro::Web.load_locale(f) }
73
+ end
74
+
75
+ middlewares = @middlewares.dup
76
+ Rack::Builder.new do
77
+ use Rack::Session::Cookie,
78
+ key: "_side_bro_session",
79
+ same_site: :strict,
80
+ secret: ENV.fetch("SIDE_BRO_SESSION_SECRET") { SecureRandom.hex(32) }
81
+
82
+ middlewares.each do |(mw, args, blk)|
83
+ blk ? use(mw, *args, &blk) : use(mw, *args)
84
+ end
85
+
86
+ run SideBro::Web::Application.new
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ # Load built-in locale files
94
+ Dir[File.expand_path("../../web/locales/*.yml", __dir__)].each do |f|
95
+ SideBro::Web.load_locale(f)
96
+ end
data/lib/side_bro.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "side_bro/version"
4
+ require_relative "side_bro/web"
5
+
6
+ module SideBro
7
+ class Error < StandardError; end
8
+ end
data/sig/side_bro.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module SideBro
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
File without changes
@@ -0,0 +1,62 @@
1
+ // SideBro — main application JS
2
+ (function () {
3
+ // Refresh button
4
+ var refreshBtn = document.getElementById("refreshBtn");
5
+ if (refreshBtn) {
6
+ refreshBtn.addEventListener("click", function () { window.location.reload(); });
7
+ }
8
+
9
+ // Bulk checkbox select-all
10
+ var selectAll = document.getElementById("select-all");
11
+ if (selectAll) {
12
+ selectAll.addEventListener("change", function () {
13
+ document.querySelectorAll('input[name="key[]"]').forEach(function (cb) {
14
+ cb.checked = selectAll.checked;
15
+ });
16
+ });
17
+ }
18
+
19
+ // Live toggle — owns button state; drives page auto-refresh on non-dashboard pages.
20
+ // Dashboard overrides behaviour by setting window.SideBroLive.onToggle.
21
+ var liveToggle = document.getElementById("liveToggle");
22
+ var intervalSec = 5;
23
+ var isLive = true;
24
+ var onDashboard = !!document.getElementById("pollSlider");
25
+
26
+ function updateLabel() {
27
+ var span = liveToggle && liveToggle.querySelector("span:last-child");
28
+ if (!span) return;
29
+ if (!isLive) { span.textContent = "Paused"; return; }
30
+ if (onDashboard) {
31
+ span.innerHTML = "Live · <span class=\"mono\" id=\"liveInterval\">" + intervalSec + "s</span>";
32
+ } else {
33
+ span.textContent = "Live";
34
+ }
35
+ }
36
+
37
+ updateLabel();
38
+
39
+ if (liveToggle) {
40
+ liveToggle.addEventListener("click", function () {
41
+ liveToggle.classList.toggle("off");
42
+ isLive = !liveToggle.classList.contains("off");
43
+ updateLabel();
44
+ if (window.SideBroLive && window.SideBroLive.onToggle) {
45
+ window.SideBroLive.onToggle(isLive);
46
+ }
47
+ });
48
+ }
49
+
50
+ // Exposed API for dashboard.js to sync interval display and pause/resume polling
51
+ window.SideBroLive = {
52
+ onToggle: null,
53
+ setInterval: function (sec) {
54
+ intervalSec = sec;
55
+ var el = document.getElementById("liveInterval");
56
+ if (el) el.textContent = sec + "s";
57
+ var pill = document.getElementById("pollPill");
58
+ if (pill) pill.textContent = sec + "s POLL";
59
+ },
60
+ getIsLive: function () { return isLive; }
61
+ };
62
+ })();
@@ -0,0 +1 @@
1
+ // Base chart utilities
@@ -0,0 +1 @@
1
+ // Dashboard charts
@@ -0,0 +1,262 @@
1
+ // SideBro Dashboard — SVG charts + live polling
2
+ (function () {
3
+ var pollSlider = document.getElementById("pollSlider");
4
+ var pollValue = document.getElementById("pollValue");
5
+ var liveInterval = document.getElementById("liveInterval");
6
+ var pollPill = document.getElementById("pollPill");
7
+ var liveToggle = document.getElementById("liveToggle");
8
+ var pollTimer = null;
9
+ var isLive = true;
10
+
11
+ function getInterval() {
12
+ return pollSlider ? parseInt(pollSlider.value, 10) : 5;
13
+ }
14
+
15
+ if (pollSlider) {
16
+ pollSlider.addEventListener("input", function (e) {
17
+ var v = parseInt(e.target.value, 10);
18
+ if (pollValue) pollValue.textContent = v + " sec";
19
+ if (window.SideBroLive) window.SideBroLive.setInterval(v);
20
+ liveInterval = document.getElementById("liveInterval");
21
+ restartTimer();
22
+ });
23
+ }
24
+
25
+ // application.js owns the toggle button; we hook in via SideBroLive.onToggle
26
+ if (window.SideBroLive) {
27
+ window.SideBroLive.onToggle = function (live) {
28
+ isLive = live;
29
+ if (!isLive) {
30
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
31
+ } else {
32
+ liveInterval = document.getElementById("liveInterval");
33
+ restartTimer();
34
+ }
35
+ };
36
+ }
37
+
38
+ var N = 60;
39
+ var seriesProcessed = new Array(N).fill(0);
40
+ var seriesFailed = new Array(N).fill(0);
41
+ var prevProcessed = null;
42
+ var prevFailed = null;
43
+
44
+ // ---- live chart ----
45
+ function renderLiveChart() {
46
+ var svg = document.getElementById("liveChart");
47
+ if (!svg) return;
48
+ var W = 1200, H = 280, PAD_L = 54, PAD_R = 20, PAD_T = 18, PAD_B = 28;
49
+ var cw = W - PAD_L - PAD_R, ch = H - PAD_T - PAD_B;
50
+ var interval = getInterval();
51
+ var totalSec = (N - 1) * interval;
52
+ var chartWindow = document.getElementById("chartWindow");
53
+ if (chartWindow) chartWindow.textContent = totalSec >= 60 ? Math.round(totalSec / 60) + " min" : totalSec + "s";
54
+ var all = seriesProcessed.concat(seriesFailed);
55
+ var maxY = Math.max(12, Math.ceil(Math.max.apply(null, all)));
56
+ var x = function (i) { return PAD_L + (i / (N - 1)) * cw; };
57
+ var y = function (v) { return PAD_T + (1 - v / maxY) * ch; };
58
+
59
+ function makePath(s) {
60
+ var d = "";
61
+ s.forEach(function (v, i) { d += (i === 0 ? "M" : "L") + x(i).toFixed(1) + "," + y(v).toFixed(1) + " "; });
62
+ return d;
63
+ }
64
+ function makeArea(s) {
65
+ var d = "M" + x(0) + "," + y(0) + " ";
66
+ s.forEach(function (v, i) { d += "L" + x(i).toFixed(1) + "," + y(v).toFixed(1) + " "; });
67
+ d += "L" + x(N - 1) + "," + y(0) + " Z";
68
+ return d;
69
+ }
70
+
71
+ var grid = "";
72
+ for (var i = 0; i <= 4; i++) {
73
+ var yy = PAD_T + (i / 4) * ch;
74
+ var val = Math.round(maxY - (i / 4) * maxY);
75
+ grid += "<line x1=\"" + PAD_L + "\" y1=\"" + yy + "\" x2=\"" + (W - PAD_R) + "\" y2=\"" + yy + "\" stroke=\"#1f1638\" stroke-dasharray=\"3 4\"/>";
76
+ grid += "<text x=\"" + (PAD_L - 8) + "\" y=\"" + (yy + 4) + "\" font-size=\"11\" fill=\"#6a5f8a\" text-anchor=\"end\" font-family=\"JetBrains Mono\">" + val + "</text>";
77
+ }
78
+ var xLabels = "";
79
+ for (var j = 0; j <= 4; j++) {
80
+ var xx = PAD_L + (j / 4) * cw;
81
+ var sec = Math.round(((4 - j) / 4) * totalSec);
82
+ xLabels += "<text x=\"" + xx + "\" y=\"" + (H - 8) + "\" font-size=\"11\" fill=\"#6a5f8a\" text-anchor=\"middle\" font-family=\"JetBrains Mono\">" + (sec === 0 ? "now" : "-" + sec + "s") + "</text>";
83
+ }
84
+
85
+ svg.innerHTML =
86
+ "<defs>" +
87
+ "<linearGradient id=\"gradProc\" x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">" +
88
+ "<stop offset=\"0%\" stop-color=\"#4be3ff\" stop-opacity=\"0.45\"/>" +
89
+ "<stop offset=\"100%\" stop-color=\"#4be3ff\" stop-opacity=\"0\"/>" +
90
+ "</linearGradient>" +
91
+ "<filter id=\"glow\"><feGaussianBlur stdDeviation=\"2\"/></filter>" +
92
+ "</defs>" +
93
+ grid + xLabels +
94
+ "<path d=\"" + makeArea(seriesProcessed) + "\" fill=\"url(#gradProc)\"/>" +
95
+ "<path d=\"" + makePath(seriesProcessed) + "\" stroke=\"#4be3ff\" stroke-width=\"2\" fill=\"none\" filter=\"url(#glow)\" opacity=\"0.6\"/>" +
96
+ "<path d=\"" + makePath(seriesProcessed) + "\" stroke=\"#4be3ff\" stroke-width=\"2\" fill=\"none\"/>" +
97
+ "<path d=\"" + makePath(seriesFailed) + "\" stroke=\"#ff5470\" stroke-width=\"2\" fill=\"none\"/>" +
98
+ "<circle cx=\"" + x(N - 1) + "\" cy=\"" + y(seriesProcessed[N - 1]) + "\" r=\"4\" fill=\"#4be3ff\"/>" +
99
+ "<circle cx=\"" + x(N - 1) + "\" cy=\"" + y(seriesProcessed[N - 1]) + "\" r=\"8\" fill=\"#4be3ff\" opacity=\"0.25\"/>";
100
+
101
+ var tip = document.getElementById("chartTip");
102
+ svg.onmousemove = function (e) {
103
+ var rect = svg.getBoundingClientRect();
104
+ var px = (e.clientX - rect.left) / rect.width * W;
105
+ var idx = Math.max(0, Math.min(N - 1, Math.round(((px - PAD_L) / cw) * (N - 1))));
106
+ document.getElementById("tipProc").textContent = seriesProcessed[idx].toFixed(1);
107
+ document.getElementById("tipFail").textContent = seriesFailed[idx].toFixed(1);
108
+ var ageSec = (N - 1 - idx) * getInterval();
109
+ document.getElementById("tipTime").textContent = ageSec === 0 ? "now" : ageSec + "s ago";
110
+ tip.style.display = "block";
111
+ var cursorX = e.clientX - rect.left;
112
+ var tipW = tip.offsetWidth;
113
+ var tipX = cursorX + tipW + 24 > rect.width ? cursorX - tipW - 12 : cursorX + 12;
114
+ tip.style.left = tipX + "px";
115
+ tip.style.top = (e.clientY - rect.top + 12) + "px";
116
+ };
117
+ svg.onmouseleave = function () { tip.style.display = "none"; };
118
+ }
119
+
120
+ function formatVal(v) {
121
+ if (v >= 1000000) return (v / 1000000).toFixed(1).replace(/\.0$/, "") + "M";
122
+ if (v >= 1000) return Math.round(v / 1000) + "K";
123
+ return String(v);
124
+ }
125
+
126
+ // ---- history chart ----
127
+ var currentRange = "month";
128
+
129
+ function renderHistoryChart() {
130
+ var svg = document.getElementById("historyChart");
131
+ if (!svg) return;
132
+ var W = 1200, H = 320, PAD_L = 60, PAD_R = 30, PAD_T = 20, PAD_B = 40;
133
+ var cw = W - PAD_L - PAD_R, ch = H - PAD_T - PAD_B;
134
+
135
+ var histData = window.HISTORY_DATA;
136
+ if (!histData) return;
137
+
138
+ // select subset based on range
139
+ var allPoints = histData.processed;
140
+ var allFailed = histData.failed;
141
+ var allLabels = histData.labels;
142
+ var total = allPoints.length;
143
+
144
+ var sliceLen = total;
145
+ if (currentRange === "week") sliceLen = Math.min(7, total);
146
+ else if (currentRange === "month") sliceLen = Math.min(30, total);
147
+ else if (currentRange === "3m") sliceLen = Math.min(90, total);
148
+ else if (currentRange === "6m") sliceLen = Math.min(180, total);
149
+
150
+ var points = allPoints.slice(0, sliceLen);
151
+ var failed = allFailed.slice(0, sliceLen);
152
+ var labels = allLabels.slice(0, sliceLen);
153
+ if (points.length === 0) return;
154
+
155
+ var maxY = Math.max(1, Math.max.apply(null, points.concat(failed)));
156
+ var x = function (i) { return PAD_L + (i / Math.max(points.length - 1, 1)) * cw; };
157
+ var y = function (v) { return PAD_T + (1 - v / maxY) * ch; };
158
+
159
+ var line = "", area = "M" + x(0) + "," + y(0) + " ";
160
+ var failLine = "", failArea = "M" + x(0) + "," + y(0) + " ";
161
+ points.forEach(function (v, i) {
162
+ line += (i === 0 ? "M" : "L") + x(i).toFixed(1) + "," + y(v).toFixed(1) + " ";
163
+ area += "L" + x(i).toFixed(1) + "," + y(v).toFixed(1) + " ";
164
+ });
165
+ area += "L" + x(points.length - 1) + "," + y(0) + " Z";
166
+ failed.forEach(function (v, i) {
167
+ failLine += (i === 0 ? "M" : "L") + x(i).toFixed(1) + "," + y(v).toFixed(1) + " ";
168
+ failArea += "L" + x(i).toFixed(1) + "," + y(v).toFixed(1) + " ";
169
+ });
170
+ failArea += "L" + x(failed.length - 1) + "," + y(0) + " Z";
171
+
172
+ var grid = "";
173
+ for (var i = 0; i <= 4; i++) {
174
+ var yy = PAD_T + (i / 4) * ch;
175
+ var val = Math.round(maxY - (i / 4) * maxY);
176
+ grid += "<line x1=\"" + PAD_L + "\" y1=\"" + yy + "\" x2=\"" + (W - PAD_R) + "\" y2=\"" + yy + "\" stroke=\"#1f1638\" stroke-dasharray=\"2 4\"/>";
177
+ if (i > 0) grid += "<text x=\"" + (PAD_L - 10) + "\" y=\"" + (yy + 4) + "\" font-size=\"11\" fill=\"#6a5f8a\" text-anchor=\"end\" font-family=\"JetBrains Mono\">" + formatVal(val) + "</text>";
178
+ }
179
+
180
+ // label thinning
181
+ var labelStep = Math.max(1, Math.floor(labels.length / 6));
182
+ var xLabels = "";
183
+ labels.forEach(function (l, i) {
184
+ if (i % labelStep !== 0 && i !== labels.length - 1) return;
185
+ var xx = x(i);
186
+ xLabels += "<line x1=\"" + xx + "\" y1=\"" + PAD_T + "\" x2=\"" + xx + "\" y2=\"" + (H - PAD_B) + "\" stroke=\"#1f1638\" stroke-dasharray=\"2 4\"/>";
187
+ xLabels += "<text x=\"" + xx + "\" y=\"" + (H - 14) + "\" font-size=\"12\" fill=\"#a98cff\" text-anchor=\"middle\" font-family=\"JetBrains Mono\">" + l + "</text>";
188
+ });
189
+
190
+ svg.innerHTML =
191
+ "<defs>" +
192
+ "<linearGradient id=\"gradHist\" x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">" +
193
+ "<stop offset=\"0%\" stop-color=\"#a98cff\" stop-opacity=\"0.35\"/>" +
194
+ "<stop offset=\"60%\" stop-color=\"#a98cff\" stop-opacity=\"0.05\"/>" +
195
+ "<stop offset=\"100%\" stop-color=\"#a98cff\" stop-opacity=\"0\"/>" +
196
+ "</linearGradient>" +
197
+ "<linearGradient id=\"gradHistLine\" x1=\"0\" x2=\"1\" y1=\"0\" y2=\"0\">" +
198
+ "<stop offset=\"0%\" stop-color=\"#a98cff\"/>" +
199
+ "<stop offset=\"60%\" stop-color=\"#ff5cd6\"/>" +
200
+ "<stop offset=\"100%\" stop-color=\"#7a3dff\"/>" +
201
+ "</linearGradient>" +
202
+ "<linearGradient id=\"gradFail\" x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">" +
203
+ "<stop offset=\"0%\" stop-color=\"#ff5470\" stop-opacity=\"0.25\"/>" +
204
+ "<stop offset=\"100%\" stop-color=\"#ff5470\" stop-opacity=\"0\"/>" +
205
+ "</linearGradient>" +
206
+ "</defs>" +
207
+ grid + xLabels +
208
+ "<path d=\"" + area + "\" fill=\"url(#gradHist)\"/>" +
209
+ "<path d=\"" + failArea + "\" fill=\"url(#gradFail)\"/>" +
210
+ "<path d=\"" + line + "\" stroke=\"url(#gradHistLine)\" stroke-width=\"2.4\" fill=\"none\" stroke-linejoin=\"round\" stroke-linecap=\"round\"/>" +
211
+ "<path d=\"" + failLine + "\" stroke=\"#ff5470\" stroke-width=\"1.6\" fill=\"none\" stroke-linejoin=\"round\" stroke-linecap=\"round\"/>" +
212
+ points.map(function (v, i) {
213
+ return "<circle cx=\"" + x(i).toFixed(1) + "\" cy=\"" + y(v).toFixed(1) + "\" r=\"2.4\" fill=\"#170f29\" stroke=\"#a98cff\" stroke-width=\"1.5\"/>";
214
+ }).join("");
215
+ }
216
+
217
+ // ---- live polling via fetch ----
218
+ function fetchStats() {
219
+ if (!isLive) return;
220
+ var root = window.SIDE_BRO_ROOT || "/";
221
+ fetch(root + "stats")
222
+ .then(function (r) { return r.json(); })
223
+ .then(function (data) {
224
+ var proc = data.processed || 0;
225
+ var fail = data.failed || 0;
226
+ var deltaProc = prevProcessed === null ? 0 : Math.max(0, proc - prevProcessed);
227
+ var deltaFail = prevFailed === null ? 0 : Math.max(0, fail - prevFailed);
228
+ prevProcessed = proc;
229
+ prevFailed = fail;
230
+ seriesProcessed.shift(); seriesProcessed.push(deltaProc);
231
+ seriesFailed.shift(); seriesFailed.push(deltaFail);
232
+ renderLiveChart();
233
+ })
234
+ .catch(function () {
235
+ renderLiveChart();
236
+ });
237
+ }
238
+
239
+ function restartTimer() {
240
+ if (pollTimer) clearInterval(pollTimer);
241
+ if (!isLive) return;
242
+ pollTimer = setInterval(fetchStats, getInterval() * 1000);
243
+ }
244
+
245
+ // ---- history tabs ----
246
+ document.querySelectorAll("#historyTabs .chip").forEach(function (c) {
247
+ c.addEventListener("click", function () {
248
+ document.querySelectorAll("#historyTabs .chip").forEach(function (x) { x.classList.remove("active"); });
249
+ c.classList.add("active");
250
+ currentRange = c.dataset.range;
251
+ renderHistoryChart();
252
+ });
253
+ });
254
+
255
+ // ---- init ----
256
+ var chartTime = document.getElementById("chartTime");
257
+ if (chartTime) chartTime.textContent = new Date().toUTCString().replace(" GMT", "") + " UTC";
258
+
259
+ renderLiveChart();
260
+ renderHistoryChart();
261
+ restartTimer();
262
+ })();
@@ -0,0 +1 @@
1
+ // Metrics charts