pbx 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/examples/pbx.example.yml +10 -0
- data/exe/pbx +6 -0
- data/lib/pbx/ami_bridge.rb +456 -0
- data/lib/pbx/app.rb +376 -0
- data/lib/pbx/call.rb +9 -0
- data/lib/pbx/call_queue.rb +9 -0
- data/lib/pbx/cli.rb +46 -0
- data/lib/pbx/config.rb +45 -0
- data/lib/pbx/messages.rb +205 -0
- data/lib/pbx/peer.rb +5 -0
- data/lib/pbx/queue_member.rb +7 -0
- data/lib/pbx/status.rb +84 -0
- data/lib/pbx/version.rb +5 -0
- data/lib/pbx/views/active_calls.rb +101 -0
- data/lib/pbx/views/disconnected_screen.rb +60 -0
- data/lib/pbx/views/extension_table.rb +84 -0
- data/lib/pbx/views/footer.rb +51 -0
- data/lib/pbx/views/header.rb +63 -0
- data/lib/pbx/views/info_modal.rb +75 -0
- data/lib/pbx/views/queue_table.rb +62 -0
- data/lib/pbx.rb +20 -0
- metadata +205 -0
data/lib/pbx/status.rb
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pbx
|
|
4
|
+
module Status
|
|
5
|
+
STATUS_COLORS = {
|
|
6
|
+
"registered" => "#22c55e",
|
|
7
|
+
"reachable" => "#22c55e",
|
|
8
|
+
"lagged" => "#f59e0b",
|
|
9
|
+
"unreachable" => "#ef4444",
|
|
10
|
+
"unregistered" => "#6b7280",
|
|
11
|
+
"unmonitored" => "#6b7280",
|
|
12
|
+
"unknown" => "#6b7280"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
STATUS_LABELS = {
|
|
16
|
+
"registered" => "Registered",
|
|
17
|
+
"reachable" => "Reachable",
|
|
18
|
+
"lagged" => "Lagged",
|
|
19
|
+
"unreachable" => "Unreachable",
|
|
20
|
+
"unregistered" => "Unregistered",
|
|
21
|
+
"unmonitored" => "Unmonitored",
|
|
22
|
+
"unknown" => "Unknown"
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def self.describe(status)
|
|
26
|
+
STATUS_LABELS[status.to_s.downcase] || status.to_s.capitalize
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.color(status)
|
|
30
|
+
STATUS_COLORS[status.to_s.downcase] || "#6b7280"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Normalises the raw AMI Status string from SIPpeers/PeerStatus into a
|
|
34
|
+
# lowercase key used by describe/color.
|
|
35
|
+
def self.from_sip(raw)
|
|
36
|
+
return "unknown" unless raw
|
|
37
|
+
|
|
38
|
+
s = raw.to_s.downcase
|
|
39
|
+
return "registered" if s.start_with?("ok")
|
|
40
|
+
return "unreachable" if s.include?("unreachable")
|
|
41
|
+
return "lagged" if s.include?("lagged")
|
|
42
|
+
return "unregistered" if s.include?("unregistered")
|
|
43
|
+
return "unmonitored" if s.include?("unmonitored")
|
|
44
|
+
return "registered" if s.include?("registered") || s.include?("reachable")
|
|
45
|
+
|
|
46
|
+
"unknown"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
QUEUE_MEMBER_STATES = {
|
|
50
|
+
"1" => "not_in_use",
|
|
51
|
+
"2" => "in_use",
|
|
52
|
+
"3" => "busy",
|
|
53
|
+
"5" => "unavailable",
|
|
54
|
+
"6" => "ringing",
|
|
55
|
+
"8" => "on_hold"
|
|
56
|
+
}.freeze
|
|
57
|
+
|
|
58
|
+
def self.queue_member_state(code)
|
|
59
|
+
QUEUE_MEMBER_STATES[code.to_s] || "unknown"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Maps PJSIP DeviceState strings ("Not in use", "In use", "Unavailable"…)
|
|
63
|
+
# to the same internal keys used by describe/color.
|
|
64
|
+
# Any reachable state (in use, ringing, on hold…) maps to "registered" so
|
|
65
|
+
# the peers table shows the endpoint as up.
|
|
66
|
+
def self.from_pjsip_device_state(raw)
|
|
67
|
+
return "unknown" unless raw
|
|
68
|
+
|
|
69
|
+
s = raw.to_s.downcase
|
|
70
|
+
return "unknown" if s.empty?
|
|
71
|
+
return "unreachable" if s.include?("unavailable") || s.include?("invalid")
|
|
72
|
+
|
|
73
|
+
"registered"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Extracts the RTT in milliseconds from strings like "OK (5 ms)".
|
|
77
|
+
def self.rtt_from_sip(raw)
|
|
78
|
+
return nil unless raw
|
|
79
|
+
|
|
80
|
+
match = raw.match(/\((\d+)\s*ms\)/i)
|
|
81
|
+
match ? match[1].to_i : nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/pbx/version.rb
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lipgloss"
|
|
4
|
+
require "bubbles"
|
|
5
|
+
|
|
6
|
+
module Pbx
|
|
7
|
+
module Views
|
|
8
|
+
module ActiveCalls
|
|
9
|
+
COLUMNS = [
|
|
10
|
+
{title: "Channel", width: 14},
|
|
11
|
+
{title: "State", width: 14},
|
|
12
|
+
{title: "App", width: 14},
|
|
13
|
+
{title: "With", width: 18},
|
|
14
|
+
{title: "For", width: 10}
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
HEADER_STYLE = Lipgloss::Style.new.bold(true).foreground("#f59e0b")
|
|
18
|
+
SELECTED_STYLE = Lipgloss::Style.new.bold(true).foreground("#ffffff").background("#d97706")
|
|
19
|
+
TITLE_STYLE = Lipgloss::Style.new.bold(true).foreground("#f59e0b").padding(0, 1)
|
|
20
|
+
SEP_STYLE = Lipgloss::Style.new.foreground("#4b5563")
|
|
21
|
+
|
|
22
|
+
STATE_SYMBOLS = {
|
|
23
|
+
"up" => "▶",
|
|
24
|
+
"ringing" => "◎",
|
|
25
|
+
"ring" => "◎",
|
|
26
|
+
"dialing" => "→",
|
|
27
|
+
"offhook" => "·"
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
OUTCOME_SYMBOLS = {
|
|
31
|
+
"ANSWER" => "▶",
|
|
32
|
+
"BUSY" => "✕",
|
|
33
|
+
"NOANSWER" => "⌀",
|
|
34
|
+
"CANCEL" => "←",
|
|
35
|
+
"CHANUNAVAIL" => "✗",
|
|
36
|
+
"CONGESTION" => "!"
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
OUTCOME_LABELS = {
|
|
40
|
+
"BUSY" => "Busy",
|
|
41
|
+
"NOANSWER" => "No answer",
|
|
42
|
+
"CANCEL" => "Cancelled",
|
|
43
|
+
"CHANUNAVAIL" => "Unavailable",
|
|
44
|
+
"CONGESTION" => "Congestion"
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
def self.render(calls, width, table_height = 1, table: nil)
|
|
48
|
+
sep = SEP_STYLE.render("─" * [width, 1].max)
|
|
49
|
+
title = TITLE_STYLE.render("Active Calls (#{calls.size})")
|
|
50
|
+
t = table || build(calls, table_height)
|
|
51
|
+
Lipgloss.join_vertical(:left, sep, title, t.view)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.build(calls, table_height)
|
|
55
|
+
rows = calls.values
|
|
56
|
+
.sort_by { |c| c.started_at || Time.now }
|
|
57
|
+
.map { |call|
|
|
58
|
+
[
|
|
59
|
+
short_channel(call.channel),
|
|
60
|
+
state_text(call),
|
|
61
|
+
call.dialplan_app || "—",
|
|
62
|
+
call.connected_to.to_s.empty? ? "—" : call.connected_to,
|
|
63
|
+
duration(call.started_at)
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
Bubbles::Table.new(columns: COLUMNS, rows: rows, height: table_height).tap do |t|
|
|
68
|
+
t.header_style = HEADER_STYLE
|
|
69
|
+
t.selected_style = SELECTED_STYLE
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.short_channel(channel)
|
|
74
|
+
return "—" unless channel
|
|
75
|
+
|
|
76
|
+
channel.sub(/\A(SIP|PJSIP)\//i, "").split("-").first || channel
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.state_text(call)
|
|
80
|
+
return "⏸ Hold" if call.held
|
|
81
|
+
|
|
82
|
+
if call.outcome && call.outcome != "ANSWER"
|
|
83
|
+
sym = OUTCOME_SYMBOLS.fetch(call.outcome, "?")
|
|
84
|
+
label = OUTCOME_LABELS.fetch(call.outcome, call.outcome.downcase.capitalize)
|
|
85
|
+
return "#{sym} #{label}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
key = call.state.to_s.downcase
|
|
89
|
+
symbol = STATE_SYMBOLS.fetch(key, "·")
|
|
90
|
+
"#{symbol} #{call.state.to_s.capitalize}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.duration(started_at)
|
|
94
|
+
return "—" unless started_at
|
|
95
|
+
|
|
96
|
+
secs = (Time.now - started_at).to_i
|
|
97
|
+
"#{secs / 60}m #{(secs % 60).to_s.rjust(2, "0")}s"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lipgloss"
|
|
4
|
+
|
|
5
|
+
module Pbx
|
|
6
|
+
module Views
|
|
7
|
+
module DisconnectedScreen
|
|
8
|
+
ICON_STYLE = Lipgloss::Style.new
|
|
9
|
+
.foreground("#4b5563")
|
|
10
|
+
.bold(true)
|
|
11
|
+
|
|
12
|
+
TITLE_STYLE = Lipgloss::Style.new
|
|
13
|
+
.foreground("#9ca3af")
|
|
14
|
+
.bold(true)
|
|
15
|
+
|
|
16
|
+
BODY_STYLE = Lipgloss::Style.new
|
|
17
|
+
.foreground("#6b7280")
|
|
18
|
+
|
|
19
|
+
CODE_STYLE = Lipgloss::Style.new
|
|
20
|
+
.foreground("#a78bfa")
|
|
21
|
+
.bold(true)
|
|
22
|
+
|
|
23
|
+
HINT_STYLE = Lipgloss::Style.new
|
|
24
|
+
.foreground("#4b5563")
|
|
25
|
+
.italic(true)
|
|
26
|
+
|
|
27
|
+
BOX_STYLE = Lipgloss::Style.new
|
|
28
|
+
.border(:rounded)
|
|
29
|
+
.border_foreground("#374151")
|
|
30
|
+
.padding(1, 3)
|
|
31
|
+
.margin(1, 2)
|
|
32
|
+
|
|
33
|
+
def self.call(state)
|
|
34
|
+
missing = missing_params(state.config)
|
|
35
|
+
|
|
36
|
+
lines = [
|
|
37
|
+
ICON_STYLE.render("◌") + " " + TITLE_STYLE.render("No connection configured"),
|
|
38
|
+
"",
|
|
39
|
+
BODY_STYLE.render("Missing parameters: ") + missing.map { |p| CODE_STYLE.render("--#{p}") }.join(" "),
|
|
40
|
+
"",
|
|
41
|
+
BODY_STYLE.render("Example:"),
|
|
42
|
+
" " + CODE_STYLE.render("pbx monitor --host 192.168.1.10 --user admin --secret s3cret"),
|
|
43
|
+
"",
|
|
44
|
+
HINT_STYLE.render("or use a config file:"),
|
|
45
|
+
" " + CODE_STYLE.render("pbx monitor --config /path/to/pbx.yml")
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
BOX_STYLE.render(lines.join("\n"))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.missing_params(config)
|
|
52
|
+
params = []
|
|
53
|
+
params << "host" if config.host == "127.0.0.1"
|
|
54
|
+
params << "user" if config.user.to_s.strip.empty?
|
|
55
|
+
params << "secret" if config.secret.to_s.strip.empty?
|
|
56
|
+
params
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lipgloss"
|
|
4
|
+
require "bubbles"
|
|
5
|
+
require_relative "../status"
|
|
6
|
+
|
|
7
|
+
module Pbx
|
|
8
|
+
module Views
|
|
9
|
+
module ExtensionTable
|
|
10
|
+
COLUMNS = [
|
|
11
|
+
{title: "Peer", width: 16},
|
|
12
|
+
{title: "Status", width: 14},
|
|
13
|
+
{title: "IP Address", width: 17},
|
|
14
|
+
{title: "Port", width: 6},
|
|
15
|
+
{title: "Type", width: 8},
|
|
16
|
+
{title: "RTT (ms)", width: 9},
|
|
17
|
+
{title: "Changed", width: 12}
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
HEADER_STYLE = Lipgloss::Style.new.bold(true).foreground("#a78bfa")
|
|
21
|
+
SELECTED_STYLE = Lipgloss::Style.new.bold(true).foreground("#ffffff").background("#7c3aed")
|
|
22
|
+
|
|
23
|
+
EMPTY_STYLE = Lipgloss::Style.new.foreground("#6b7280").italic(true).padding(2, 4)
|
|
24
|
+
|
|
25
|
+
STATUS_DOTS = {
|
|
26
|
+
"registered" => "●",
|
|
27
|
+
"reachable" => "●",
|
|
28
|
+
"lagged" => "◑",
|
|
29
|
+
"unreachable" => "○",
|
|
30
|
+
"unregistered" => "·",
|
|
31
|
+
"unmonitored" => "·",
|
|
32
|
+
"unknown" => "·"
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
def self.build(peers, table_height)
|
|
36
|
+
rows = peers.values.sort_by(&:name).map { |peer|
|
|
37
|
+
[
|
|
38
|
+
peer.name,
|
|
39
|
+
status_text(peer.status),
|
|
40
|
+
peer.ip_address || "—",
|
|
41
|
+
peer.ip_port&.to_s || "—",
|
|
42
|
+
peer.type || "—",
|
|
43
|
+
peer.rtt_ms&.to_s || "—",
|
|
44
|
+
since(peer.last_change_at)
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
Bubbles::Table.new(columns: COLUMNS, rows: rows, height: table_height).tap do |t|
|
|
49
|
+
t.header_style = HEADER_STYLE
|
|
50
|
+
t.selected_style = SELECTED_STYLE
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.render_empty
|
|
55
|
+
EMPTY_STYLE.render("No SIP peers discovered yet…")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Plain-text status for table cells — ANSI codes break Bubbles::Table width math.
|
|
59
|
+
def self.status_text(status)
|
|
60
|
+
dot = STATUS_DOTS.fetch(status.to_s, "·")
|
|
61
|
+
label = Status.describe(status)
|
|
62
|
+
"#{dot} #{label}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# ANSI-colored status for use outside the table (info panels, etc.).
|
|
66
|
+
def self.colorized_status(status)
|
|
67
|
+
color = Status.color(status)
|
|
68
|
+
label = Status.describe(status)
|
|
69
|
+
Lipgloss::Style.new.foreground(color).bold(true).render(label)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.since(time)
|
|
73
|
+
return "—" unless time
|
|
74
|
+
|
|
75
|
+
secs = (Time.now - time).to_i
|
|
76
|
+
return "Just now" if secs < 5
|
|
77
|
+
return "#{secs}s ago" if secs < 60
|
|
78
|
+
return "#{secs / 60}m ago" if secs < 3600
|
|
79
|
+
|
|
80
|
+
"#{secs / 3600}h ago"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lipgloss"
|
|
4
|
+
|
|
5
|
+
module Pbx
|
|
6
|
+
module Views
|
|
7
|
+
module Footer
|
|
8
|
+
HINT_STYLE = Lipgloss::Style.new.foreground("#6b7280").faint(true)
|
|
9
|
+
KEY_STYLE = Lipgloss::Style.new.foreground("#9ca3af").bold(true)
|
|
10
|
+
SEP_STYLE = Lipgloss::Style.new.foreground("#4b5563")
|
|
11
|
+
|
|
12
|
+
ACTIVE_KEY_STYLE = Lipgloss::Style.new.foreground("#f59e0b").bold(true)
|
|
13
|
+
|
|
14
|
+
def self.call(state)
|
|
15
|
+
sep = SEP_STYLE.render("─" * ((state.width > 0) ? state.width : 80))
|
|
16
|
+
hints = build_hints(state)
|
|
17
|
+
|
|
18
|
+
right = if state.status == :disconnected
|
|
19
|
+
HINT_STYLE.render("pbx monitor --host HOST --user USER --secret SECRET")
|
|
20
|
+
else
|
|
21
|
+
HINT_STYLE.render("#{state.extensions.size} extension(s)")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
bar = Lipgloss.join_horizontal(:center, hints, " ", right)
|
|
25
|
+
Lipgloss.join_vertical(:left, sep, bar)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.build_hints(state)
|
|
29
|
+
connected = state.status == :connected
|
|
30
|
+
|
|
31
|
+
tab_peers = tab_hint("p", "peers", connected && state.view_mode == :peers)
|
|
32
|
+
tab_calls = tab_hint("c", "calls", connected && state.view_mode == :calls)
|
|
33
|
+
tab_queues = tab_hint("q", "queues", connected && state.view_mode == :queues)
|
|
34
|
+
|
|
35
|
+
static = [
|
|
36
|
+
"#{KEY_STYLE.render("↑/↓")} #{HINT_STYLE.render("scroll")}",
|
|
37
|
+
"#{KEY_STYLE.render("i")} #{HINT_STYLE.render("info")}",
|
|
38
|
+
"#{KEY_STYLE.render("e/Esc")} #{HINT_STYLE.render("quit")}"
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
([tab_peers, tab_calls, tab_queues] + static).join(" ")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.tab_hint(key, label, active)
|
|
45
|
+
key_s = active ? ACTIVE_KEY_STYLE.render(key) : KEY_STYLE.render(key)
|
|
46
|
+
lbl_s = active ? ACTIVE_KEY_STYLE.render(label) : HINT_STYLE.render(label)
|
|
47
|
+
"#{key_s} #{lbl_s}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lipgloss"
|
|
4
|
+
|
|
5
|
+
module Pbx
|
|
6
|
+
module Views
|
|
7
|
+
module Header
|
|
8
|
+
TITLE_STYLE = Lipgloss::Style.new
|
|
9
|
+
.bold(true)
|
|
10
|
+
.foreground("#7c3aed")
|
|
11
|
+
.padding(0, 1)
|
|
12
|
+
|
|
13
|
+
STATUS_DISCONNECTED_STYLE = Lipgloss::Style.new.foreground("#6b7280").italic(true)
|
|
14
|
+
STATUS_CONNECTING_STYLE = Lipgloss::Style.new.foreground("#f59e0b").italic(true)
|
|
15
|
+
STATUS_CONNECTED_STYLE = Lipgloss::Style.new.foreground("#22c55e").bold(true)
|
|
16
|
+
STATUS_LOST_STYLE = Lipgloss::Style.new.foreground("#ef4444").bold(true)
|
|
17
|
+
|
|
18
|
+
SEPARATOR_STYLE = Lipgloss::Style.new.foreground("#4b5563")
|
|
19
|
+
SYSINFO_STYLE = Lipgloss::Style.new.foreground("#6b7280")
|
|
20
|
+
|
|
21
|
+
def self.call(state)
|
|
22
|
+
title = TITLE_STYLE.render("PBX Monitor")
|
|
23
|
+
remote = "#{state.config.host}:#{state.config.port}"
|
|
24
|
+
status = case state.status
|
|
25
|
+
when :disconnected then STATUS_DISCONNECTED_STYLE.render("◌ Disconnected")
|
|
26
|
+
when :connecting then STATUS_CONNECTING_STYLE.render("#{state.spinner_view} Connecting to #{remote}…")
|
|
27
|
+
when :connected then STATUS_CONNECTED_STYLE.render("● #{remote}")
|
|
28
|
+
when :lost then STATUS_LOST_STYLE.render("✗ #{remote} #{state.error}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sysinfo = build_sysinfo(state)
|
|
32
|
+
line = if sysinfo
|
|
33
|
+
Lipgloss.join_horizontal(:center, title, " ", status, " ", sysinfo)
|
|
34
|
+
else
|
|
35
|
+
Lipgloss.join_horizontal(:center, title, " ", status)
|
|
36
|
+
end
|
|
37
|
+
sep = SEPARATOR_STYLE.render("─" * ((state.width > 0) ? state.width : 80))
|
|
38
|
+
Lipgloss.join_vertical(:left, line, sep)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.build_sysinfo(state)
|
|
42
|
+
return nil unless state.system_boot_at || state.last_reload_at
|
|
43
|
+
|
|
44
|
+
parts = []
|
|
45
|
+
parts << "Up #{format_duration((Time.now - state.system_boot_at).to_i)}" if state.system_boot_at
|
|
46
|
+
parts << "Reload #{format_duration((Time.now - state.last_reload_at).to_i)}" if state.last_reload_at
|
|
47
|
+
SYSINFO_STYLE.render(parts.join(" · "))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.format_duration(secs)
|
|
51
|
+
d = secs / 86_400
|
|
52
|
+
h = (secs % 86_400) / 3_600
|
|
53
|
+
m = (secs % 3_600) / 60
|
|
54
|
+
|
|
55
|
+
parts = []
|
|
56
|
+
parts << "#{d}d" if d > 0
|
|
57
|
+
parts << "#{h}h" if h > 0 || d > 0
|
|
58
|
+
parts << "#{m}m"
|
|
59
|
+
parts.join(" ")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lipgloss"
|
|
4
|
+
require_relative "../version"
|
|
5
|
+
|
|
6
|
+
module Pbx
|
|
7
|
+
module Views
|
|
8
|
+
module InfoModal
|
|
9
|
+
TITLE_STYLE = Lipgloss::Style.new
|
|
10
|
+
.bold(true)
|
|
11
|
+
.foreground("#a78bfa")
|
|
12
|
+
.align(:center)
|
|
13
|
+
|
|
14
|
+
VERSION_STYLE = Lipgloss::Style.new
|
|
15
|
+
.foreground("#6b7280")
|
|
16
|
+
.align(:center)
|
|
17
|
+
|
|
18
|
+
LABEL_STYLE = Lipgloss::Style.new
|
|
19
|
+
.foreground("#9ca3af")
|
|
20
|
+
|
|
21
|
+
VALUE_STYLE = Lipgloss::Style.new
|
|
22
|
+
.foreground("#e5e7eb")
|
|
23
|
+
.bold(true)
|
|
24
|
+
|
|
25
|
+
URL_STYLE = Lipgloss::Style.new
|
|
26
|
+
.foreground("#60a5fa")
|
|
27
|
+
.underline(true)
|
|
28
|
+
|
|
29
|
+
HINT_STYLE = Lipgloss::Style.new
|
|
30
|
+
.foreground("#4b5563")
|
|
31
|
+
.italic(true)
|
|
32
|
+
.align(:center)
|
|
33
|
+
|
|
34
|
+
BOX_STYLE = Lipgloss::Style.new
|
|
35
|
+
.border(:rounded)
|
|
36
|
+
.border_foreground("#7c3aed")
|
|
37
|
+
.padding(1, 4)
|
|
38
|
+
|
|
39
|
+
REPO_URL = "https://github.com/emilianodellacasa/pbx"
|
|
40
|
+
|
|
41
|
+
def self.call(state)
|
|
42
|
+
inner_width = 36
|
|
43
|
+
|
|
44
|
+
title = TITLE_STYLE.width(inner_width).render("PBX Monitor")
|
|
45
|
+
version = VERSION_STYLE.width(inner_width).render("v#{Pbx::VERSION}")
|
|
46
|
+
|
|
47
|
+
divider = Lipgloss::Style.new.foreground("#374151").render("─" * inner_width)
|
|
48
|
+
|
|
49
|
+
author_row = "#{LABEL_STYLE.render("Author")} #{VALUE_STYLE.render("Emiliano Della Casa")}"
|
|
50
|
+
repo_row = "#{LABEL_STYLE.render("GitHub")} #{URL_STYLE.render(REPO_URL)}"
|
|
51
|
+
|
|
52
|
+
hint = HINT_STYLE.width(inner_width).render("Press any key to close")
|
|
53
|
+
|
|
54
|
+
content = Lipgloss.join_vertical(
|
|
55
|
+
:center,
|
|
56
|
+
title,
|
|
57
|
+
version,
|
|
58
|
+
"",
|
|
59
|
+
divider,
|
|
60
|
+
"",
|
|
61
|
+
author_row,
|
|
62
|
+
repo_row,
|
|
63
|
+
"",
|
|
64
|
+
hint
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
modal = BOX_STYLE.render(content)
|
|
68
|
+
|
|
69
|
+
w = (state.width > 0) ? state.width : 80
|
|
70
|
+
h = (state.height > 0) ? state.height : 24
|
|
71
|
+
Lipgloss.place(w, h, :center, :center, modal)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lipgloss"
|
|
4
|
+
require "bubbles"
|
|
5
|
+
|
|
6
|
+
module Pbx
|
|
7
|
+
module Views
|
|
8
|
+
module QueueTable
|
|
9
|
+
COLUMNS = [
|
|
10
|
+
{title: "Queue", width: 16},
|
|
11
|
+
{title: "Strategy", width: 12},
|
|
12
|
+
{title: "Waiting", width: 9},
|
|
13
|
+
{title: "Agents", width: 8},
|
|
14
|
+
{title: "Completed", width: 11},
|
|
15
|
+
{title: "Abandoned", width: 11},
|
|
16
|
+
{title: "Hold", width: 10}
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
HEADER_STYLE = Lipgloss::Style.new.bold(true).foreground("#34d399")
|
|
20
|
+
SELECTED_STYLE = Lipgloss::Style.new.bold(true).foreground("#ffffff").background("#059669")
|
|
21
|
+
TITLE_STYLE = Lipgloss::Style.new.bold(true).foreground("#34d399").padding(0, 1)
|
|
22
|
+
SEP_STYLE = Lipgloss::Style.new.foreground("#4b5563")
|
|
23
|
+
EMPTY_STYLE = Lipgloss::Style.new.foreground("#6b7280").italic(true).padding(2, 4)
|
|
24
|
+
|
|
25
|
+
def self.render(queues, width, table_height = 1, table: nil)
|
|
26
|
+
return EMPTY_STYLE.render("No queues discovered yet…") if queues.empty?
|
|
27
|
+
|
|
28
|
+
sep = SEP_STYLE.render("─" * [width, 1].max)
|
|
29
|
+
title = TITLE_STYLE.render("Queues (#{queues.size})")
|
|
30
|
+
t = table || build(queues, table_height)
|
|
31
|
+
Lipgloss.join_vertical(:left, sep, title, t.view)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.build(queues, table_height)
|
|
35
|
+
rows = queues.values.sort_by(&:name).map { |q|
|
|
36
|
+
avail = q.members.values.count(&:available?)
|
|
37
|
+
total = q.members.size
|
|
38
|
+
[
|
|
39
|
+
q.name,
|
|
40
|
+
q.strategy,
|
|
41
|
+
q.calls_waiting.to_s,
|
|
42
|
+
"#{avail}/#{total}",
|
|
43
|
+
q.completed.to_s,
|
|
44
|
+
q.abandoned.to_s,
|
|
45
|
+
format_holdtime(q.last_holdtime || q.holdtime)
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
Bubbles::Table.new(columns: COLUMNS, rows: rows, height: table_height).tap do |t|
|
|
50
|
+
t.header_style = HEADER_STYLE
|
|
51
|
+
t.selected_style = SELECTED_STYLE
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.format_holdtime(secs)
|
|
56
|
+
return "—" if secs.nil? || secs == 0
|
|
57
|
+
|
|
58
|
+
"#{secs / 60}m #{(secs % 60).to_s.rjust(2, "0")}s"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/pbx.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "pbx/version"
|
|
4
|
+
require_relative "pbx/messages"
|
|
5
|
+
require_relative "pbx/peer"
|
|
6
|
+
require_relative "pbx/call"
|
|
7
|
+
require_relative "pbx/call_queue"
|
|
8
|
+
require_relative "pbx/queue_member"
|
|
9
|
+
require_relative "pbx/status"
|
|
10
|
+
require_relative "pbx/config"
|
|
11
|
+
require_relative "pbx/ami_bridge"
|
|
12
|
+
require_relative "pbx/views/header"
|
|
13
|
+
require_relative "pbx/views/extension_table"
|
|
14
|
+
require_relative "pbx/views/footer"
|
|
15
|
+
require_relative "pbx/views/active_calls"
|
|
16
|
+
require_relative "pbx/views/queue_table"
|
|
17
|
+
require_relative "pbx/views/disconnected_screen"
|
|
18
|
+
require_relative "pbx/views/info_modal"
|
|
19
|
+
require_relative "pbx/app"
|
|
20
|
+
require_relative "pbx/cli"
|