cosmonats 0.8.0 → 0.9.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.
- checksums.yaml +4 -4
- data/README.md +21 -11
- data/lib/cosmo/api/counter.rb +1 -1
- data/lib/cosmo/api/cron/entry.rb +10 -11
- data/lib/cosmo/api/cron.rb +3 -3
- data/lib/cosmo/api/job.rb +1 -1
- data/lib/cosmo/api/kv.rb +3 -3
- data/lib/cosmo/api/stats/busy.rb +5 -0
- data/lib/cosmo/api/stats/metrics.rb +3 -3
- data/lib/cosmo/api/stats.rb +15 -0
- data/lib/cosmo/api/stream.rb +7 -2
- data/lib/cosmo/client.rb +10 -13
- data/lib/cosmo/job/data.rb +1 -1
- data/lib/cosmo/job/processor.rb +5 -6
- data/lib/cosmo/utils.rb +0 -1
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +271 -629
- data/lib/cosmo/web/assets/app.js +158 -0
- data/lib/cosmo/web/assets/fonts/OFL.txt +92 -0
- data/lib/cosmo/web/assets/fonts/geist-mono.woff2 +0 -0
- data/lib/cosmo/web/assets/fonts/geist.woff2 +0 -0
- data/lib/cosmo/web/assets/logo.svg +10 -0
- data/lib/cosmo/web/cache.rb +75 -0
- data/lib/cosmo/web/chart.rb +62 -38
- data/lib/cosmo/web/circuit.rb +55 -0
- data/lib/cosmo/web/context.rb +0 -4
- data/lib/cosmo/web/controllers/application.rb +16 -0
- data/lib/cosmo/web/controllers/batches.rb +1 -0
- data/lib/cosmo/web/controllers/crons.rb +9 -2
- data/lib/cosmo/web/controllers/jobs.rb +50 -59
- data/lib/cosmo/web/controllers/metrics.rb +1 -0
- data/lib/cosmo/web/controllers/overview.rb +70 -0
- data/lib/cosmo/web/controllers/processes.rb +33 -0
- data/lib/cosmo/web/controllers/streams.rb +26 -28
- data/lib/cosmo/web/helpers/application.rb +57 -0
- data/lib/cosmo/web/renderer.rb +19 -6
- data/lib/cosmo/web/throughput.rb +104 -0
- data/lib/cosmo/web/views/batches/_table.erb +42 -54
- data/lib/cosmo/web/views/batches/index.erb +3 -10
- data/lib/cosmo/web/views/crons/_table.erb +32 -53
- data/lib/cosmo/web/views/crons/index.erb +2 -9
- data/lib/cosmo/web/views/jobs/_args.erb +5 -0
- data/lib/cosmo/web/views/jobs/_busy.erb +25 -121
- data/lib/cosmo/web/views/jobs/_dead.erb +42 -82
- data/lib/cosmo/web/views/jobs/_enqueued.erb +33 -113
- data/lib/cosmo/web/views/jobs/_panel.erb +29 -0
- data/lib/cosmo/web/views/jobs/_queue_row.erb +18 -0
- data/lib/cosmo/web/views/jobs/_queues.erb +20 -0
- data/lib/cosmo/web/views/jobs/_scheduled.erb +20 -54
- data/lib/cosmo/web/views/jobs/index.erb +4 -17
- data/lib/cosmo/web/views/layout.erb +73 -22
- data/lib/cosmo/web/views/metrics/_chart.erb +37 -32
- data/lib/cosmo/web/views/metrics/_panel.erb +3 -3
- data/lib/cosmo/web/views/metrics/_table.erb +24 -40
- data/lib/cosmo/web/views/metrics/index.erb +8 -16
- data/lib/cosmo/web/views/overview/_badges.erb +3 -0
- data/lib/cosmo/web/views/overview/_kpis.erb +19 -0
- data/lib/cosmo/web/views/overview/_running.erb +18 -0
- data/lib/cosmo/web/views/overview/_throughput.erb +50 -0
- data/lib/cosmo/web/views/overview/_workers.erb +25 -0
- data/lib/cosmo/web/views/overview/index.erb +28 -0
- data/lib/cosmo/web/views/processes/_table.erb +67 -0
- data/lib/cosmo/web/views/processes/index.erb +1 -0
- data/lib/cosmo/web/views/shared/_pager.erb +22 -0
- data/lib/cosmo/web/views/shared/unavailable.erb +7 -0
- data/lib/cosmo/web/views/streams/_info.erb +28 -86
- data/lib/cosmo/web/views/streams/_list.erb +7 -0
- data/lib/cosmo/web/views/streams/_pause_banner.erb +8 -16
- data/lib/cosmo/web/views/streams/_stream.erb +52 -0
- data/lib/cosmo/web/views/streams/index.erb +3 -11
- data/lib/cosmo/web/views/streams/info.erb +3 -11
- data/lib/cosmo/web.rb +66 -35
- data/sig/cosmo/api/cron/entry.rbs +2 -1
- data/sig/cosmo/api/stats/busy.rbs +2 -0
- data/sig/cosmo/api/stats/metrics.rbs +1 -1
- data/sig/cosmo/api/stats.rbs +4 -0
- data/sig/cosmo/api/stream.rbs +2 -0
- data/sig/cosmo/client.rbs +1 -0
- metadata +31 -18
- data/lib/cosmo/utils/overrides.rb +0 -206
- data/lib/cosmo/utils/warnings.rb +0 -17
- data/lib/cosmo/web/controllers/actions.rb +0 -16
- data/lib/cosmo/web/views/actions/index.erb +0 -7
- data/lib/cosmo/web/views/jobs/_processes.erb +0 -145
- data/lib/cosmo/web/views/jobs/_stats.erb +0 -77
- data/lib/cosmo/web/views/jobs/_tabs.erb +0 -14
- data/lib/cosmo/web/views/jobs/busy.erb +0 -18
- data/lib/cosmo/web/views/jobs/dead.erb +0 -18
- data/lib/cosmo/web/views/jobs/enqueued.erb +0 -18
- data/lib/cosmo/web/views/jobs/scheduled.erb +0 -18
- data/lib/cosmo/web/views/streams/_stream_row.erb +0 -42
- data/lib/cosmo/web/views/streams/_table.erb +0 -25
|
@@ -7,123 +7,114 @@ module Cosmo
|
|
|
7
7
|
module Controllers
|
|
8
8
|
class Jobs < Application
|
|
9
9
|
def index
|
|
10
|
-
|
|
11
|
-
processes_query = params.slice("page", "limit").transform_values(&:to_i).select { _2.positive? }
|
|
12
|
-
ok render("jobs/index", { processes_query: }, layout: true)
|
|
10
|
+
tab("busy")
|
|
13
11
|
end
|
|
14
12
|
|
|
15
13
|
def busy
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
content_for :title, "Busy Jobs"
|
|
19
|
-
ok render("jobs/busy", layout: true)
|
|
14
|
+
tab("busy")
|
|
20
15
|
end
|
|
21
16
|
|
|
22
17
|
def enqueued
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
content_for :title, "Enqueued Jobs"
|
|
26
|
-
stream_name, _stream_names = streams
|
|
27
|
-
ok render("jobs/enqueued", { stream_name: }, layout: true)
|
|
18
|
+
tab("enqueued")
|
|
28
19
|
end
|
|
29
20
|
|
|
30
21
|
def scheduled
|
|
31
22
|
return not_found unless Config.scheduled.enabled
|
|
32
23
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
content_for :title, "Scheduled Jobs"
|
|
36
|
-
ok render("jobs/scheduled", layout: true)
|
|
24
|
+
tab("scheduled")
|
|
37
25
|
end
|
|
38
26
|
|
|
39
27
|
def dead
|
|
40
28
|
return not_found unless Config.dead.enabled
|
|
41
29
|
|
|
42
|
-
|
|
30
|
+
tab("dead")
|
|
31
|
+
end
|
|
43
32
|
|
|
44
|
-
|
|
45
|
-
ok render("jobs/
|
|
33
|
+
def _queues
|
|
34
|
+
ok render("jobs/_queues", { queues: API::Stats.queues, compact: params["compact"].to_s == "1" })
|
|
46
35
|
end
|
|
47
36
|
|
|
48
37
|
def retry
|
|
49
38
|
return not_found unless Config.dead.enabled
|
|
50
39
|
|
|
51
40
|
seq = path.split("/").last.to_i
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
ok
|
|
41
|
+
API::Stream.new("dead").retry(seq)
|
|
42
|
+
ok flash("Dead job ##{seq} sent back to its queue")
|
|
55
43
|
end
|
|
56
44
|
|
|
57
45
|
def delete
|
|
58
46
|
return not_found unless Config.dead.enabled
|
|
59
47
|
|
|
60
48
|
seq = path.split("/").last.to_i
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
ok
|
|
49
|
+
API::Stream.new("dead").delete(seq)
|
|
50
|
+
ok flash("Dead job ##{seq} deleted")
|
|
64
51
|
end
|
|
65
52
|
|
|
66
53
|
def delete_enqueued
|
|
67
54
|
seq = path.split("/").last.to_i
|
|
68
55
|
stream_name, = streams
|
|
69
56
|
API::Stream.new(stream_name).delete(seq)
|
|
70
|
-
|
|
57
|
+
ok panel("enqueued") + flash("Message ##{seq} removed from #{stream_name}")
|
|
71
58
|
end
|
|
72
59
|
|
|
73
|
-
|
|
74
|
-
return not_found unless Config.scheduled.enabled
|
|
60
|
+
private
|
|
75
61
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
62
|
+
def tab(name)
|
|
63
|
+
return ok(panel(name)) if hx_request?
|
|
64
|
+
|
|
65
|
+
content_for :title, "Jobs"
|
|
66
|
+
content_for :subtitle, "Queues, in-flight work and everything waiting its turn."
|
|
67
|
+
ok render("jobs/index", { panel: panel(name) }, layout: true)
|
|
79
68
|
end
|
|
80
69
|
|
|
81
|
-
def
|
|
82
|
-
|
|
70
|
+
def panel(name)
|
|
71
|
+
render("jobs/_panel", send("#{name}_locals").merge(tab: name, counts: counts))
|
|
72
|
+
end
|
|
83
73
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
74
|
+
def counts
|
|
75
|
+
counts = { busy: API::Stats.busy, enqueued: API::Stats.enqueued }
|
|
76
|
+
counts[:scheduled] = API::Stats.scheduled if Config.scheduled.enabled
|
|
77
|
+
counts[:dead] = API::Stats.dead if Config.dead.enabled
|
|
78
|
+
counts
|
|
87
79
|
end
|
|
88
80
|
|
|
89
|
-
def
|
|
81
|
+
def busy_locals
|
|
90
82
|
busy = API::Stats::Busy.instance
|
|
91
83
|
total = busy.size
|
|
92
84
|
page, limit, total_pages = paginate(total, API::Stats::Busy::LIMIT)
|
|
93
85
|
polling = params["poll"].to_s != "0"
|
|
94
|
-
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def _processes
|
|
98
|
-
processes = API::Stats::Processes.instance
|
|
99
|
-
total = processes.size
|
|
100
|
-
page, limit, total_pages = paginate(total, API::Stats::Processes::LIMIT)
|
|
101
|
-
polling = params["poll"].to_s != "0"
|
|
102
|
-
groups = processes.list(page:, limit:).chunk { API::Stats::Processes.kind(_1) }.to_a
|
|
103
|
-
ok render("jobs/_processes", { groups:, total:, page:, limit:, total_pages:, polling: })
|
|
86
|
+
{ jobs: busy.list(page:, limit:), total:, page:, limit:, total_pages:, polling:, query: { limit:, poll: (0 unless polling) }.compact }
|
|
104
87
|
end
|
|
105
88
|
|
|
106
|
-
def
|
|
89
|
+
def enqueued_locals # rubocop:disable Metrics/AbcSize
|
|
107
90
|
stream_name, stream_names = streams
|
|
108
91
|
limit = (params["limit"] || API::Stream::LIMIT).to_i
|
|
109
92
|
page = [params["page"].to_i, 1].max
|
|
93
|
+
locals = { stream_name:, stream_names:, page:, limit:, total: 0, total_pages: 0, jobs: nil, polling: false,
|
|
94
|
+
query: { stream_name: Rack::Utils.escape(stream_name.to_s), limit: } }
|
|
95
|
+
return locals if stream_name.to_s.empty?
|
|
110
96
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
end
|
|
97
|
+
stream = API::Stream.new(stream_name)
|
|
98
|
+
total = stream.total
|
|
99
|
+
total_pages = (total.to_f / limit).ceil
|
|
100
|
+
page = page.clamp(1, [total_pages, 1].max)
|
|
101
|
+
locals.merge(total:, total_pages:, page:, jobs: stream.messages(page:, limit:))
|
|
102
|
+
end
|
|
118
103
|
|
|
119
|
-
|
|
104
|
+
def scheduled_locals
|
|
105
|
+
stored_locals("scheduled").merge(polling: true)
|
|
120
106
|
end
|
|
121
107
|
|
|
122
|
-
def
|
|
123
|
-
|
|
108
|
+
def dead_locals
|
|
109
|
+
stored_locals("dead").merge(polling: false)
|
|
124
110
|
end
|
|
125
111
|
|
|
126
|
-
|
|
112
|
+
def stored_locals(name)
|
|
113
|
+
stream = API::Stream.new(name)
|
|
114
|
+
total = stream.total
|
|
115
|
+
page, limit, total_pages = paginate(total, API::Stream::LIMIT)
|
|
116
|
+
{ jobs: stream.messages(page:, limit:), total:, page:, limit:, total_pages:, query: { limit: } }
|
|
117
|
+
end
|
|
127
118
|
|
|
128
119
|
def paginate(total, default_limit)
|
|
129
120
|
limit = (params["limit"] || default_limit).to_i
|
|
@@ -13,6 +13,7 @@ module Cosmo
|
|
|
13
13
|
return not_found unless Config.metrics.enabled
|
|
14
14
|
|
|
15
15
|
content_for :title, "Metrics"
|
|
16
|
+
content_for :subtitle, "Runs, failures, execution and wait time per job class."
|
|
16
17
|
ok render("metrics/index", { days:, ranges: RANGES }, layout: true)
|
|
17
18
|
end
|
|
18
19
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cosmo/web/controllers/application"
|
|
4
|
+
require "cosmo/web/throughput"
|
|
5
|
+
|
|
6
|
+
module Cosmo
|
|
7
|
+
class Web
|
|
8
|
+
module Controllers
|
|
9
|
+
class Overview < Application
|
|
10
|
+
RANGES = [7, 30].freeze
|
|
11
|
+
CELLS = 256
|
|
12
|
+
RUNNING = 9
|
|
13
|
+
PROCESSES = 5
|
|
14
|
+
|
|
15
|
+
def index
|
|
16
|
+
content_for :title, "Overview"
|
|
17
|
+
content_for :subtitle, "Everything beamed through NATS, live."
|
|
18
|
+
ok render("overview/index", { metrics: Config.metrics.enabled }, layout: true)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def _kpis
|
|
22
|
+
ok render("overview/_kpis", API::Stats.summary)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def _throughput
|
|
26
|
+
return not_found unless Config.metrics.enabled
|
|
27
|
+
|
|
28
|
+
days = RANGES.include?(params["days"].to_i) ? params["days"].to_i : RANGES.first
|
|
29
|
+
chart = Throughput.new(API::Stats::Metrics.instance.daily(days:))
|
|
30
|
+
ok render("overview/_throughput", { chart:, days:, ranges: RANGES })
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def _workers # rubocop:disable Metrics/AbcSize
|
|
34
|
+
processes = API::Stats::Processes.instance.list(limit: API::Stats::Processes.instance.size.clamp(1, nil))
|
|
35
|
+
busy = API::Stats::Busy.instance.all.group_by { _1[:worker] }
|
|
36
|
+
cells = processes.flat_map { worker_cells(_1, busy) }
|
|
37
|
+
ok render("overview/_workers", { processes: processes.first(PROCESSES), total: processes.size,
|
|
38
|
+
cells: cells.first(CELLS), hidden: [cells.size - CELLS, 0].max,
|
|
39
|
+
busy: cells.count(&:itself), threads: cells.size })
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def _running
|
|
43
|
+
jobs = API::Stats::Busy.instance.all.sort_by { _1[:started_at].to_i }.first(RUNNING)
|
|
44
|
+
jobs = jobs.map { _1.merge(data: Utils::Json.parse(_1[:data]) || {}) }
|
|
45
|
+
ok render("overview/_running", { jobs:, total: API::Stats::Busy.instance.size })
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def _badges
|
|
49
|
+
badges = { jobs: API::Stats.enqueued, streams: visible_streams.size }
|
|
50
|
+
badges[:crons] = API::Cron.instance.all.size if Config.scheduled.enabled
|
|
51
|
+
badges[:batches] = API::Batch.kv.keys("*.meta", limit: nil).size
|
|
52
|
+
ok render("overview/_badges", { badges: })
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# A process's threads, each the stream of the job it runs or nil while idle.
|
|
58
|
+
def worker_cells(process, busy)
|
|
59
|
+
streams = Array(busy[process[:identity]]).map { _1[:stream] }
|
|
60
|
+
idle = [process[:concurrency].to_i - streams.size, 0].max
|
|
61
|
+
streams + Array.new(idle)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def visible_streams
|
|
65
|
+
API::Stream.all.reject { _1.name.start_with?("KV_", "_cosmo") }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cosmo/web/controllers/application"
|
|
4
|
+
|
|
5
|
+
module Cosmo
|
|
6
|
+
class Web
|
|
7
|
+
module Controllers
|
|
8
|
+
class Processes < Application
|
|
9
|
+
def index
|
|
10
|
+
return ok(table) if hx_request?
|
|
11
|
+
|
|
12
|
+
content_for :title, "Processes"
|
|
13
|
+
content_for :subtitle, "Every worker process heartbeating into NATS."
|
|
14
|
+
ok render("processes/index", { table: table }, layout: true)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def table # rubocop:disable Metrics/AbcSize
|
|
20
|
+
processes = API::Stats::Processes.instance
|
|
21
|
+
total = processes.size
|
|
22
|
+
limit = (params["limit"] || API::Stats::Processes::LIMIT).to_i
|
|
23
|
+
total_pages = (total.to_f / limit).ceil
|
|
24
|
+
page = params["page"].to_i.clamp(1, [total_pages, 1].max)
|
|
25
|
+
polling = params["poll"].to_s != "0"
|
|
26
|
+
groups = processes.list(page:, limit:).chunk { API::Stats::Processes.kind(_1) }.to_a
|
|
27
|
+
render("processes/_table", { groups:, total:, page:, limit:, total_pages:, polling:,
|
|
28
|
+
query: { limit:, poll: (0 unless polling) }.compact })
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -7,62 +7,60 @@ module Cosmo
|
|
|
7
7
|
module Controllers
|
|
8
8
|
class Streams < Application
|
|
9
9
|
def index
|
|
10
|
-
return
|
|
10
|
+
return ok(list) if hx_request?
|
|
11
11
|
|
|
12
12
|
content_for :title, "Streams"
|
|
13
|
+
content_for :subtitle, "JetStream streams and the consumers reading them."
|
|
13
14
|
ok render("streams/index", layout: true)
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def info
|
|
17
|
-
|
|
18
|
-
return _info if hx_request?
|
|
18
|
+
return ok(render("streams/_info", info_locals(API::Stream.new(name)))) if hx_request?
|
|
19
19
|
|
|
20
20
|
content_for :title, "Streams"
|
|
21
|
-
|
|
21
|
+
content_for :subtitle, "Configuration of #{name}."
|
|
22
|
+
ok render("streams/info", { name: }, layout: true)
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
def pause
|
|
25
|
-
name = Rack::Utils.unescape(@request.params["name"])
|
|
26
26
|
stream = API::Stream.new(name)
|
|
27
27
|
stream.pause!
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
ok render("streams/_stream_row", { stream: row_locals(stream) })
|
|
28
|
+
toggled(stream, "paused")
|
|
31
29
|
end
|
|
32
30
|
|
|
33
31
|
def unpause
|
|
34
|
-
name = Rack::Utils.unescape(@request.params["name"])
|
|
35
32
|
stream = API::Stream.new(name)
|
|
36
33
|
stream.unpause!
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
ok render("streams/_stream_row", { stream: row_locals(stream) })
|
|
34
|
+
toggled(stream, "resumed")
|
|
40
35
|
end
|
|
41
36
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def name
|
|
40
|
+
Rack::Utils.unescape(params["name"].to_s)
|
|
45
41
|
end
|
|
46
42
|
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
ok render("streams/_info", stream.info.merge(name:, paused: stream.paused?))
|
|
43
|
+
def list
|
|
44
|
+
streams = API::Stream.all.reject { _1.name.start_with?("KV_", "_cosmo") }.map { card(_1) }
|
|
45
|
+
render("streams/_list", { streams: })
|
|
51
46
|
end
|
|
52
47
|
|
|
53
|
-
|
|
48
|
+
def toggled(stream, verb)
|
|
49
|
+
body = case params["view"] || (params["banner"] && "banner")
|
|
50
|
+
when "banner" then render("streams/_pause_banner", { name: stream.name, paused: stream.paused? })
|
|
51
|
+
when "queue" then render("jobs/_queue_row", { queue: API::Stats.queue(stream), compact: false })
|
|
52
|
+
else render("streams/_stream", { stream: card(stream) })
|
|
53
|
+
end
|
|
54
|
+
ok body + flash("#{stream.name} #{verb}")
|
|
55
|
+
end
|
|
54
56
|
|
|
55
|
-
def
|
|
57
|
+
def card(stream)
|
|
56
58
|
state, config = stream.info.values
|
|
57
|
-
{ name: stream.name,
|
|
58
|
-
first_seq: state.first_seq, last_seq: state.last_seq,
|
|
59
|
-
consumer_count: state.consumer_count,
|
|
60
|
-
subjects: config.subjects,
|
|
61
|
-
paused: stream.paused? }
|
|
59
|
+
{ name: stream.name, state:, config:, consumers: stream.consumers, paused: stream.paused? }
|
|
62
60
|
end
|
|
63
61
|
|
|
64
|
-
def
|
|
65
|
-
|
|
62
|
+
def info_locals(stream)
|
|
63
|
+
stream.info.merge(name: stream.name, paused: stream.paused?)
|
|
66
64
|
end
|
|
67
65
|
end
|
|
68
66
|
end
|
|
@@ -78,6 +78,63 @@ module Cosmo
|
|
|
78
78
|
"#{(diff / 86_400).to_i}d"
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
+
# @return [String] e.g. "940", "18.9k", "1.28M"
|
|
82
|
+
def compact_number(num)
|
|
83
|
+
n = num.to_i
|
|
84
|
+
return format("%.2fM", n / 1e6).sub(/\.?0+M\z/, "M") if n >= 1_000_000
|
|
85
|
+
return format("%.1fk", n / 1e3).sub(/\.0k\z/, "k") if n >= 10_000
|
|
86
|
+
|
|
87
|
+
format_numbers(n)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @param seconds [Numeric, nil]
|
|
91
|
+
# @return [String] e.g. "45s", "30m", "7d", or "—" without a value
|
|
92
|
+
def format_span(seconds)
|
|
93
|
+
s = seconds.to_i
|
|
94
|
+
return "—" unless s.positive?
|
|
95
|
+
|
|
96
|
+
{ 86_400 => "d", 3_600 => "h", 60 => "m" }.each { |size, unit| return "#{(s / size.to_f).round(1).to_s.delete_suffix(".0")}#{unit}" if s >= size }
|
|
97
|
+
"#{s}s"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A stable chart color per name, one of the palette's five hues: job streams take them in name order, so the
|
|
101
|
+
# first five never share one, anything else by a hash of its name.
|
|
102
|
+
def hue(name)
|
|
103
|
+
@hues ||= API::Stream.jobs.map(&:name).sort.each_with_index.to_h
|
|
104
|
+
index = @hues.fetch(name.to_s) { Zlib.crc32(name.to_s) }
|
|
105
|
+
"var(--c#{(index % 5) + 1})"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Array<Hash>] the sidebar entries, +:key+, +:label+ and +:path+, for the enabled pages
|
|
109
|
+
def nav_items
|
|
110
|
+
items = [%w[overview Overview /], %w[jobs Jobs /jobs], %w[streams Streams /streams]]
|
|
111
|
+
items << %w[crons Crons /crons] if Config.scheduled.enabled
|
|
112
|
+
items << %w[batches Batches /batches]
|
|
113
|
+
items << %w[metrics Metrics /metrics] if Config.metrics.enabled
|
|
114
|
+
items.map { |key, label, path| { key:, label:, path: } }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @return [String] the nav key of the page being rendered
|
|
118
|
+
def current_section
|
|
119
|
+
path = @request.path_info
|
|
120
|
+
return "overview" if path.empty? || path == "/" || path.start_with?("/processes")
|
|
121
|
+
|
|
122
|
+
path.split("/")[1].to_s
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @return [Hash] the connected NATS server's +:url+, +:name+ and +:version+, empty while unknown
|
|
126
|
+
def nats_server
|
|
127
|
+
return {} if Web::Circuit.instance.open?
|
|
128
|
+
|
|
129
|
+
connection = Client.instance.nc
|
|
130
|
+
info = connection.server_info || {}
|
|
131
|
+
uri = connection.connected_server
|
|
132
|
+
name = info[:server_name] unless info[:server_name] == info[:server_id]
|
|
133
|
+
{ url: uri && "#{uri.host}:#{uri.port}", name:, version: info[:version] }.compact
|
|
134
|
+
rescue StandardError
|
|
135
|
+
{}
|
|
136
|
+
end
|
|
137
|
+
|
|
81
138
|
def h(value)
|
|
82
139
|
Rack::Utils.escape_html(value.to_s)
|
|
83
140
|
end
|
data/lib/cosmo/web/renderer.rb
CHANGED
|
@@ -6,6 +6,22 @@ module Cosmo
|
|
|
6
6
|
ASSETS_ROOT = File.expand_path("assets", __dir__).freeze
|
|
7
7
|
VIEWS_ROOT = File.expand_path("views", __dir__).freeze
|
|
8
8
|
|
|
9
|
+
@templates = {}
|
|
10
|
+
@lock = Mutex.new
|
|
11
|
+
|
|
12
|
+
# Compiles a view into a Context method on its first render, so later renders skip reading and parsing it.
|
|
13
|
+
#
|
|
14
|
+
# @param path [String] the view, relative to VIEWS_ROOT and without +.erb+, e.g. +"jobs/_busy"+
|
|
15
|
+
# @return [String] the name of the Context method that renders it
|
|
16
|
+
def self.template(path)
|
|
17
|
+
@lock.synchronize do
|
|
18
|
+
@templates[path] ||= "template#{@templates.size}".tap do |name|
|
|
19
|
+
source = File.read(File.join(VIEWS_ROOT, "#{path}.erb"))
|
|
20
|
+
ERB.new(source, trim_mode: "-").def_method(Context, name, "#{path}.erb")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
9
25
|
def redirect_to(url, status = 302)
|
|
10
26
|
[status, { "location" => url_for(url) }, []]
|
|
11
27
|
end
|
|
@@ -14,7 +30,7 @@ module Cosmo
|
|
|
14
30
|
path = File.join(ASSETS_ROOT, filename)
|
|
15
31
|
return not_found unless File.exist?(path)
|
|
16
32
|
|
|
17
|
-
respond_with 200, body: File.
|
|
33
|
+
respond_with 200, body: File.binread(path), headers: { "content-type" => content_type }.merge(headers || {})
|
|
18
34
|
end
|
|
19
35
|
|
|
20
36
|
def ok(body = "")
|
|
@@ -27,7 +43,7 @@ module Cosmo
|
|
|
27
43
|
end
|
|
28
44
|
|
|
29
45
|
def not_found
|
|
30
|
-
body = "<div class
|
|
46
|
+
body = "<div class=\"empty\"><b>404</b>Not found</div>"
|
|
31
47
|
headers = { "content-type" => "text/html; charset=utf-8" }
|
|
32
48
|
respond_with 404, body:, headers:
|
|
33
49
|
end
|
|
@@ -48,10 +64,7 @@ module Cosmo
|
|
|
48
64
|
end
|
|
49
65
|
|
|
50
66
|
def erb(path, locals, content_for = nil)
|
|
51
|
-
|
|
52
|
-
erb = ERB.new(File.read(path), trim_mode: "-")
|
|
53
|
-
context = Context.new(locals, content_for)
|
|
54
|
-
erb.result(context.binding)
|
|
67
|
+
Context.new(locals, content_for).public_send(Renderer.template(path))
|
|
55
68
|
end
|
|
56
69
|
end
|
|
57
70
|
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
class Web
|
|
5
|
+
# Geometry for the Overview's throughput chart: an area line of the successful runs per day and a bar per day for
|
|
6
|
+
# the failed ones, drawn into a viewBox the page stretches to its width, axes and hover targets laid out in percent.
|
|
7
|
+
class Throughput
|
|
8
|
+
WIDTH = 1000
|
|
9
|
+
HEIGHT = 220
|
|
10
|
+
PLOT = 214
|
|
11
|
+
FAILED = 54
|
|
12
|
+
BAR = 22
|
|
13
|
+
TICKS = 3
|
|
14
|
+
LABELS = 5
|
|
15
|
+
|
|
16
|
+
# @param series [Array<Hash>] +{ date:, count:, failed:, exec_ms: }+ per day, oldest first
|
|
17
|
+
def initialize(series)
|
|
18
|
+
@series = series
|
|
19
|
+
@top = top(series.map { _1[:count] }.max.to_i)
|
|
20
|
+
@failed_top = [series.map { _1[:failed] }.max.to_i, 1].max
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Boolean]
|
|
24
|
+
def empty?
|
|
25
|
+
@series.all? { _1[:count].zero? && _1[:failed].zero? }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [String] the path of the runs line
|
|
29
|
+
def line
|
|
30
|
+
@series.each_with_index.map { |day, index| "#{index.zero? ? "M" : "L"}#{x(index)} #{y(day[:count])}" }.join(" ")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [String] the path of the area under the runs line
|
|
34
|
+
def area
|
|
35
|
+
"#{line} L#{x(@series.size - 1)} #{HEIGHT} L#{x(0)} #{HEIGHT} Z"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [String] the path of the failure bars, one per day with failures
|
|
39
|
+
def failures # rubocop:disable Metrics/AbcSize
|
|
40
|
+
width = ((WIDTH / [@series.size, 1].max.to_f) * 0.42).clamp(2, BAR).round(1)
|
|
41
|
+
@series.each_with_index.filter_map do |day, index|
|
|
42
|
+
next if day[:failed].zero?
|
|
43
|
+
|
|
44
|
+
height = [(day[:failed] / @failed_top.to_f) * FAILED, 1].max.round(1)
|
|
45
|
+
"M#{(x(index) - (width / 2)).round(1)} #{HEIGHT} v#{-height} h#{width} v#{height} Z"
|
|
46
|
+
end.join(" ")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [String] the path of the horizontal grid lines
|
|
50
|
+
def grid
|
|
51
|
+
(0..TICKS).map { "M0 #{(HEIGHT - (PLOT * _1 / TICKS.to_f)).round(1)} H#{WIDTH}" }.join(" ")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Array<Integer>] the y axis values, top down
|
|
55
|
+
def ticks
|
|
56
|
+
TICKS.downto(0).map { (@top * _1 / TICKS.to_f).round }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Array<String>] the x axis labels, evenly spread
|
|
60
|
+
def labels
|
|
61
|
+
count = [LABELS, @series.size].min
|
|
62
|
+
return [] if count.zero?
|
|
63
|
+
|
|
64
|
+
(0...count).map { label(@series[count == 1 ? 0 : ((@series.size - 1) * _1 / (count - 1).to_f).round][:date]) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Array<Hash>] per day the hover column +left+/+width+ and the dot +top+, in percent, with the numbers
|
|
68
|
+
def points # rubocop:disable Metrics/AbcSize
|
|
69
|
+
slot = 100.0 / [@series.size, 1].max
|
|
70
|
+
@series.each_with_index.map do |day, index|
|
|
71
|
+
center = @series.size == 1 ? 50.0 : x(index) / WIDTH * 100
|
|
72
|
+
day.merge(left: (center - (slot / 2)).round(2), width: slot.round(2), center: center,
|
|
73
|
+
top: (y(day[:count]) / HEIGHT * 100).round(2), label: label(day[:date]))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def x(index)
|
|
80
|
+
return WIDTH / 2.0 if @series.size == 1
|
|
81
|
+
|
|
82
|
+
(index * WIDTH / (@series.size - 1).to_f).round(1)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def y(value)
|
|
86
|
+
(HEIGHT - (value / @top.to_f * PLOT)).round(1)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The smallest "nice" axis maximum (1, 2 or 5 times a power of ten per tick) at or above +max+.
|
|
90
|
+
def top(max)
|
|
91
|
+
return TICKS if max.zero?
|
|
92
|
+
|
|
93
|
+
raw = max / TICKS.to_f
|
|
94
|
+
magnitude = 10**Math.log10(raw).floor
|
|
95
|
+
step = [1, 2, 5, 10].map { _1 * magnitude }.find { _1 >= raw }
|
|
96
|
+
step * TICKS
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def label(date)
|
|
100
|
+
"#{date[4, 2]}/#{date[6, 2]}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|