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.
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pbx
4
+ VERSION = "0.1.0"
5
+ end
@@ -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"