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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE.txt +21 -0
- data/README.md +50 -0
- data/Rakefile +10 -0
- data/lib/side_bro/version.rb +5 -0
- data/lib/side_bro/web/action.rb +115 -0
- data/lib/side_bro/web/application.rb +331 -0
- data/lib/side_bro/web/helpers.rb +176 -0
- data/lib/side_bro/web/router.rb +50 -0
- data/lib/side_bro/web.rb +96 -0
- data/lib/side_bro.rb +8 -0
- data/sig/side_bro.rbs +4 -0
- data/web/assets/images/.keep +0 -0
- data/web/assets/javascripts/application.js +62 -0
- data/web/assets/javascripts/base-charts.js +1 -0
- data/web/assets/javascripts/dashboard-charts.js +1 -0
- data/web/assets/javascripts/dashboard.js +262 -0
- data/web/assets/javascripts/metrics.js +1 -0
- data/web/assets/stylesheets/style.css +636 -0
- data/web/locales/en.yml +88 -0
- data/web/views/_footer.html.erb +3 -0
- data/web/views/_job_info.html.erb +23 -0
- data/web/views/_metrics_period_select.html.erb +5 -0
- data/web/views/_nav.html.erb +76 -0
- data/web/views/_paging.html.erb +11 -0
- data/web/views/_poll_link.html.erb +4 -0
- data/web/views/_summary.html.erb +44 -0
- data/web/views/busy.html.erb +104 -0
- data/web/views/dashboard.html.erb +124 -0
- data/web/views/dead.html.erb +31 -0
- data/web/views/layout.html.erb +61 -0
- data/web/views/metrics.html.erb +56 -0
- data/web/views/metrics_for_job.html.erb +67 -0
- data/web/views/morgue.html.erb +82 -0
- data/web/views/queue.html.erb +190 -0
- data/web/views/queues.html.erb +59 -0
- data/web/views/retries.html.erb +99 -0
- data/web/views/retry.html.erb +32 -0
- data/web/views/scheduled.html.erb +79 -0
- data/web/views/scheduled_job_info.html.erb +31 -0
- 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("&", "&")
|
|
143
|
+
.gsub("<", "<")
|
|
144
|
+
.gsub(">", ">")
|
|
145
|
+
.gsub('"', """)
|
|
146
|
+
.gsub("'", "'")
|
|
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
|
data/lib/side_bro/web.rb
ADDED
|
@@ -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
data/sig/side_bro.rbs
ADDED
|
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
|