easel-dashboard 0.4.3 → 0.5
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/bin/easel +0 -1
- data/lib/easel/build_pages.rb +56 -5
- data/lib/easel/configuration.rb +54 -6
- data/lib/easel/controller.rb +21 -0
- data/lib/easel/data_gathering.rb +144 -0
- data/lib/easel/server.rb +21 -6
- data/lib/easel/websocket.rb +140 -34
- data/lib/html/app.css.erb +77 -43
- data/lib/html/app.html.erb +62 -147
- data/lib/html/app.js.erb +177 -0
- data/lib/html/controller.js.erb +136 -0
- metadata +38 -9
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 45b20ce9712ac3e87e83e527b03665b9c64a9b707f571cfcbbd1c0989df494e8
|
4
|
+
data.tar.gz: bebb6eeafa225d2a05401fea5f8f8ec7e63674de617a811ec8b6cb2cf7a56640
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c50fd692b5fcd3826cc6b3ac80aa400e1319501303f951b576c9c4ada4fe43990a383ed7f84e70d2808363a77dd04e1f07e6b0a2218608309bbed2681a42382b
|
7
|
+
data.tar.gz: d1353d563bf9b9306ebbf5bb69e7a4b401558b4be77010320b7b734808e74124bdbfaecd12890a61a400a2432ee5d16536c909fa9c4c6f2d13885a488465f788
|
data/bin/easel
CHANGED
data/lib/easel/build_pages.rb
CHANGED
@@ -29,20 +29,55 @@ def build_app
|
|
29
29
|
page
|
30
30
|
end
|
31
31
|
|
32
|
+
# build_js
|
33
|
+
#
|
34
|
+
#
|
35
|
+
def build_js
|
36
|
+
js_erb = File.new("#{File.dirname(__FILE__)}/../html/controller.js.erb").read
|
37
|
+
page = ERB.new(js_erb).result()
|
32
38
|
|
33
|
-
|
34
|
-
|
35
|
-
page
|
39
|
+
"HTTP/1.1 200 OK\r\n" +
|
40
|
+
"Content-Type: text/javascript; charset=UTF-8\r\n" +
|
41
|
+
"Content-Length: #{page.bytesize}\r\n" +
|
42
|
+
"Connection: close\r\n" +
|
43
|
+
"\r\n" +
|
44
|
+
page
|
45
|
+
end
|
36
46
|
|
37
|
-
|
38
|
-
|
47
|
+
|
48
|
+
# return_js
|
49
|
+
#
|
50
|
+
#
|
51
|
+
def return_js file
|
52
|
+
|
53
|
+
page = File.new("#{File.dirname(__FILE__)}/../html/#{file}").read
|
54
|
+
|
55
|
+
"HTTP/1.1 200 OK\r\n" +
|
56
|
+
"Content-Type: text/javascript; charset=UTF-8\r\n" +
|
39
57
|
"Content-Length: #{page.bytesize}\r\n" +
|
40
58
|
"Connection: close\r\n" +
|
41
59
|
"\r\n" +
|
42
60
|
page
|
43
61
|
end
|
44
62
|
|
63
|
+
# return_html
|
64
|
+
#
|
65
|
+
#
|
66
|
+
def return_html file
|
67
|
+
|
68
|
+
page = File.new("#{File.dirname(__FILE__)}/../html/#{file}").read
|
45
69
|
|
70
|
+
"HTTP/1.1 200 OK\r\n" +
|
71
|
+
"Content-Type: text/html; charset=UTF-8\r\n" +
|
72
|
+
"Content-Length: #{page.bytesize}\r\n" +
|
73
|
+
"Connection: close\r\n" +
|
74
|
+
"\r\n" +
|
75
|
+
page
|
76
|
+
end
|
77
|
+
|
78
|
+
# build_css
|
79
|
+
#
|
80
|
+
#
|
46
81
|
def build_css
|
47
82
|
error_erb = File.new("#{File.dirname(__FILE__)}/../html/app.css.erb").read
|
48
83
|
css = ERB.new(error_erb).result(binding)
|
@@ -54,3 +89,19 @@ def build_css
|
|
54
89
|
"\r\n" +
|
55
90
|
css
|
56
91
|
end
|
92
|
+
|
93
|
+
|
94
|
+
# build_error
|
95
|
+
#
|
96
|
+
#
|
97
|
+
def build_error code
|
98
|
+
error_erb = File.new("#{File.dirname(__FILE__)}/../html/error.html.erb").read
|
99
|
+
page = ERB.new(error_erb).result(binding)
|
100
|
+
|
101
|
+
"HTTP/1.1 #{code} #{@code_names[code]}\r\n" +
|
102
|
+
"Content-Type: text/html; charset=UTF-8\r\n" +
|
103
|
+
"Content-Length: #{page.bytesize}\r\n" +
|
104
|
+
"Connection: close\r\n" +
|
105
|
+
"\r\n" +
|
106
|
+
page
|
107
|
+
end
|
data/lib/easel/configuration.rb
CHANGED
@@ -11,11 +11,13 @@
|
|
11
11
|
# Global Variables
|
12
12
|
$config = {
|
13
13
|
logging: 2, # 0=Fatal, 1=Error, 2=Warning, 3=Info
|
14
|
-
port: 4200, #
|
15
|
-
hostname: 'localhost', #
|
16
|
-
log_file: STDOUT, #
|
17
|
-
title: 'Easel - Your Custom Dashboard',
|
18
|
-
|
14
|
+
port: 4200, # Port # to bind Easel to
|
15
|
+
hostname: 'localhost', # Hostname to accept. "" Accepts all connections.
|
16
|
+
log_file: STDOUT, # Where to write the logs to.
|
17
|
+
title: 'Easel - Your Custom Dashboard', # The title of the dashboard webpage.
|
18
|
+
header_logo: '', # TODO: have nil mean default to Easel's, otherwise put in src=""
|
19
|
+
header_title: '<a class="on-hover-secondary" href="https://easeldashboard.com">Easel Dashboard</a>', # TODO: the same as the logo line basically.
|
20
|
+
colours: { # The RGB values for the dashboard. TODO: accept hsl in HTML format.
|
19
21
|
surface: '#222222',
|
20
22
|
background: '#000000',
|
21
23
|
primary: '#7DF9FF',
|
@@ -28,7 +30,7 @@ $config = {
|
|
28
30
|
stdout_colour: '#ffffff',
|
29
31
|
stderr_colour: '#00FF00'
|
30
32
|
},
|
31
|
-
commands: [
|
33
|
+
commands: [ # A list of commands to allow the user to run via Easel.
|
32
34
|
{
|
33
35
|
name: 'Test 1',
|
34
36
|
cmd: 'echo "this is the output of Test 1"',
|
@@ -39,5 +41,51 @@ $config = {
|
|
39
41
|
cmd: 'echo "this is the output of Test 2"',
|
40
42
|
desc: 'Simple output test #2'
|
41
43
|
}
|
44
|
+
],
|
45
|
+
collect_data_period: 15,
|
46
|
+
dashboards: [ # A list of dashboards to display
|
47
|
+
{
|
48
|
+
name: "Server Status",
|
49
|
+
desc: "Basic server status.",
|
50
|
+
id: "A", # TODO: dashboard and element IDs should be automatically added.
|
51
|
+
elements: [
|
52
|
+
{
|
53
|
+
name: "CPU Load",
|
54
|
+
type: "time-series", # uses Time.now.strftime("%H:%M") as x asix.
|
55
|
+
data: [
|
56
|
+
{
|
57
|
+
cmd: "uptime",
|
58
|
+
name: "1min Average",
|
59
|
+
regex: "average: (\\d+.\\d+)"
|
60
|
+
},
|
61
|
+
{
|
62
|
+
cmd: "uptime",
|
63
|
+
name: "5min Average",
|
64
|
+
regex: ", (\\d+.\\d+),"
|
65
|
+
},{
|
66
|
+
cmd: "uptime",
|
67
|
+
name: "15min Average",
|
68
|
+
regex: ", (\\d+.\\d+)\\n"
|
69
|
+
}
|
70
|
+
]
|
71
|
+
},
|
72
|
+
{
|
73
|
+
name: "Memory",
|
74
|
+
type: "time-series", # uses Time.now.strftime("%H:%M") as x asix.
|
75
|
+
data: [
|
76
|
+
{
|
77
|
+
cmd: "free",
|
78
|
+
name: "Total Memory",
|
79
|
+
regex: "Mem:\\W+(\\d+)"
|
80
|
+
},
|
81
|
+
{
|
82
|
+
cmd: "free",
|
83
|
+
name: "Free Memory",
|
84
|
+
regex: "Mem:\\W+\\d+\\W+(\\d+)"
|
85
|
+
}
|
86
|
+
]
|
87
|
+
}
|
88
|
+
]
|
89
|
+
}
|
42
90
|
]
|
43
91
|
}
|
@@ -0,0 +1,21 @@
|
|
1
|
+
#!/snap/bin/ruby
|
2
|
+
#
|
3
|
+
# Author: Eric Power
|
4
|
+
|
5
|
+
# Imports
|
6
|
+
require_relative './configuration'
|
7
|
+
|
8
|
+
|
9
|
+
def launch_easel config
|
10
|
+
|
11
|
+
# r_server_data = Ractor.new config do
|
12
|
+
# 'this is a message' # TODO: Implement this.
|
13
|
+
# end
|
14
|
+
|
15
|
+
tcp_listener = Ractor.new(config) do |config|
|
16
|
+
launch_server config
|
17
|
+
end
|
18
|
+
|
19
|
+
tcp_listener.take
|
20
|
+
|
21
|
+
end
|
@@ -0,0 +1,144 @@
|
|
1
|
+
#!/snap/bin/ruby
|
2
|
+
#
|
3
|
+
# Author: Eric Power
|
4
|
+
#
|
5
|
+
# Description:
|
6
|
+
# Collects information about the system to display via the Easel dashboard.
|
7
|
+
# Uses semaphores and mutexes to provide atomic reads and writes. Allows a
|
8
|
+
# single writer at a time, but any number of readers can read at once.
|
9
|
+
#
|
10
|
+
# WARNING: this feature currently holds all information in memory, so don't
|
11
|
+
# turn this on if you're expecting to leave Easel running for a long time.
|
12
|
+
#
|
13
|
+
# Plan for adressing this:
|
14
|
+
# - Move 'historical' data to a proper datastore (eg. a SQLite database)
|
15
|
+
# - Have only the most recent readings in @collected_data, to allow the
|
16
|
+
# websocket threads to pull the up to date data from there.
|
17
|
+
# - Eventually allow the clients to query the historial datastore, so it's
|
18
|
+
# likely that the best bet is to have the database store the data with
|
19
|
+
# the time as the dense index.
|
20
|
+
|
21
|
+
|
22
|
+
# Imports
|
23
|
+
require 'thread'
|
24
|
+
require 'concurrent'
|
25
|
+
|
26
|
+
# Key Variables
|
27
|
+
@collected_data = {}
|
28
|
+
@writers_semaphore = Concurrent::Semaphore.new(0) # Used to count currently active writers
|
29
|
+
@readers_semaphore = Concurrent::Semaphore.new(0) # Used to count currently active readers
|
30
|
+
@join_mutex = Mutex.new
|
31
|
+
|
32
|
+
# launch_data_collection
|
33
|
+
#
|
34
|
+
# Launch a background thread to start collecting system info in the background.
|
35
|
+
def launch_data_collection
|
36
|
+
Thread.new do
|
37
|
+
loop do
|
38
|
+
collect_data
|
39
|
+
sleep $config[:collect_data_period]
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
|
45
|
+
# collect_data
|
46
|
+
#
|
47
|
+
# Collects information on the current state of the system, and writes it to
|
48
|
+
# @collected_data.
|
49
|
+
def collect_data
|
50
|
+
new_data = {}
|
51
|
+
|
52
|
+
# TODO: Check which element type something is to figure out how to handle it.
|
53
|
+
|
54
|
+
log_info "Collecting data."
|
55
|
+
$config[:dashboards].each{ |dashboard|
|
56
|
+
|
57
|
+
new_data[dashboard[:id]] = {}
|
58
|
+
dashboard[:elements].each_with_index{ |element, e_index|
|
59
|
+
new_data[dashboard[:id]][e_index] = {}
|
60
|
+
|
61
|
+
element[:data].each_with_index{ |data, index|
|
62
|
+
output = `#{data[:cmd]}`
|
63
|
+
log_info "Ran `#{data[:cmd]}`, got: #{output}"
|
64
|
+
begin
|
65
|
+
value = output.match(/#{data[:regex]}/)[1]
|
66
|
+
rescue NoMethodError => e
|
67
|
+
log_error "Data failed to be parsed. Regex: /#{data[:regex]}/ -- Output: #{output}"
|
68
|
+
end
|
69
|
+
new_data[dashboard[:id]][e_index][index] = [
|
70
|
+
Time.new.strftime("%H:%M:%S"),
|
71
|
+
value
|
72
|
+
]
|
73
|
+
}
|
74
|
+
}
|
75
|
+
}
|
76
|
+
write_data new_data
|
77
|
+
end
|
78
|
+
|
79
|
+
|
80
|
+
# write_data
|
81
|
+
#
|
82
|
+
# Writes the data collected to @collected_data, and handles the Readers/Writer
|
83
|
+
# problem by using semaphores and a mutex.
|
84
|
+
def write_data data
|
85
|
+
|
86
|
+
joined = false
|
87
|
+
until joined
|
88
|
+
@join_mutex.synchronize {
|
89
|
+
if @readers_semaphore.available_permits == 0 and @writers_semaphore.available_permits == 0
|
90
|
+
@writers_semaphore.release 1 # Increment @writers_semaphore
|
91
|
+
joined = true
|
92
|
+
end
|
93
|
+
}
|
94
|
+
sleep 0.05 # Wait 50ms to give another thread time to lock the @join_mutex.
|
95
|
+
end
|
96
|
+
|
97
|
+
# Write Data
|
98
|
+
data.each_key { |key|
|
99
|
+
case key
|
100
|
+
when :load
|
101
|
+
@collected_data[:load] = [] if @collected_data[:load].nil?
|
102
|
+
@collected_data[:load] << data[:load]
|
103
|
+
else
|
104
|
+
@collected_data[key] = data[key]
|
105
|
+
end
|
106
|
+
}
|
107
|
+
|
108
|
+
# Log if @collected_data has gotten too large.
|
109
|
+
if not @collected_data[:load].nil? and @collected_data[:load].length > 240
|
110
|
+
if @collected_data[:load].length > 1000
|
111
|
+
log_error "Easel dashboard has run collect_data more than a 1000 times. Memory size likely to be large."
|
112
|
+
else
|
113
|
+
log_warning "Easel dashboard starting to take up a lot of memory due to data_collection being turned on."
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
@writers_semaphore.acquire 1 # Decrement @writers_semaphore
|
118
|
+
end
|
119
|
+
|
120
|
+
|
121
|
+
# read_data
|
122
|
+
#
|
123
|
+
# Reads (copies) @collected_data, and handles the Readers/Writer
|
124
|
+
# problem by using semaphores and a mutex. Returns a copy
|
125
|
+
def read_data
|
126
|
+
|
127
|
+
joined = false
|
128
|
+
until joined
|
129
|
+
@join_mutex.synchronize {
|
130
|
+
if @writers_semaphore.available_permits == 0
|
131
|
+
@readers_semaphore.release 1 # Increment @readers_semaphore
|
132
|
+
# TODO: likely need to release the mutex.
|
133
|
+
joined = true
|
134
|
+
end
|
135
|
+
}
|
136
|
+
sleep 0.05 unless joined # Wait 50ms to give another thread time to lock the @join_mutex.
|
137
|
+
end
|
138
|
+
|
139
|
+
# Read Data
|
140
|
+
data = @collected_data.dup
|
141
|
+
|
142
|
+
@readers_semaphore.acquire 1 # Decrement @readers_semaphore
|
143
|
+
data
|
144
|
+
end
|
data/lib/easel/server.rb
CHANGED
@@ -7,8 +7,9 @@
|
|
7
7
|
|
8
8
|
# Imports
|
9
9
|
require 'socket'
|
10
|
-
require_relative './build_pages
|
10
|
+
require_relative './build_pages'
|
11
11
|
require_relative './websocket'
|
12
|
+
require_relative './data_gathering'
|
12
13
|
|
13
14
|
|
14
15
|
def launch_server
|
@@ -20,6 +21,9 @@ def launch_server
|
|
20
21
|
log_fatal "Server could not start. Error message: #{e}"
|
21
22
|
end
|
22
23
|
|
24
|
+
# Lauch data collection if turned on.
|
25
|
+
launch_data_collection unless $config[:collect_data_period] == 0
|
26
|
+
|
23
27
|
Thread.abort_on_exception = true
|
24
28
|
|
25
29
|
# Main Loop
|
@@ -29,10 +33,10 @@ def launch_server
|
|
29
33
|
handle_request client
|
30
34
|
end
|
31
35
|
}
|
32
|
-
|
33
|
-
# Handle shutting down.
|
34
|
-
rescue Interrupt
|
36
|
+
rescue Interrupt # Handle shutting down.
|
35
37
|
log_info "Interrupt received, server shutting down..."
|
38
|
+
rescue Exception => e
|
39
|
+
log_error "Unexpected error occured and closed client connection. Error: #{e}"
|
36
40
|
end
|
37
41
|
end
|
38
42
|
|
@@ -55,7 +59,6 @@ def handle_request socket
|
|
55
59
|
# TODO: Deal with HEAD request. https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4
|
56
60
|
else
|
57
61
|
# TODO: respond with an appropriate error.
|
58
|
-
response "I don't understand what you sent - go away."
|
59
62
|
socket.print "HTTP/1.1 200 OK\r\n" +
|
60
63
|
"Content-Type: text/plain\r\n" +
|
61
64
|
"Content-Length: #{response.bytesize}\r\n" +
|
@@ -78,9 +81,22 @@ def handle_get(socket, request)
|
|
78
81
|
when "/", "/index.html"
|
79
82
|
socket.print build_app
|
80
83
|
socket.close
|
84
|
+
when "/test.html" # TODO: Remove this!
|
85
|
+
socket.print return_html "test.html"
|
86
|
+
socket.close
|
81
87
|
when "/app.css"
|
82
88
|
socket.print build_css
|
83
89
|
socket.close
|
90
|
+
when "/controller.js"
|
91
|
+
log_info "building controller"
|
92
|
+
socket.print build_js
|
93
|
+
socket.close
|
94
|
+
when "/dashboardElements.js"
|
95
|
+
socket.print return_js 'dashboardElements.js'
|
96
|
+
socket.close
|
97
|
+
when "/createComponents.js"
|
98
|
+
socket.print return_js 'createComponents.js'
|
99
|
+
socket.close
|
84
100
|
else
|
85
101
|
socket.print build_error 404
|
86
102
|
socket.close
|
@@ -102,7 +118,6 @@ def read_HTTP_message socket
|
|
102
118
|
end
|
103
119
|
end
|
104
120
|
|
105
|
-
|
106
121
|
request = {fields: {}}
|
107
122
|
(request[:method], request[:url], request[:protocol]) = message[0].split(" ")
|
108
123
|
|
data/lib/easel/websocket.rb
CHANGED
@@ -9,14 +9,66 @@
|
|
9
9
|
#
|
10
10
|
# Note: Websocket code is based on the code provided in the following article:
|
11
11
|
# https://www.honeybadger.io/blog/building-a-simple-websockets-server-from-scratch-in-ruby/
|
12
|
+
#
|
13
|
+
# Overview of the websocket protocol:
|
14
|
+
# Communication over the websocket is split into two message types:
|
15
|
+
#
|
16
|
+
# 1. Command specific messages
|
17
|
+
# These messages are sent from either the client or the server, and
|
18
|
+
# are associated with a particular command via an ID. This ID is set
|
19
|
+
# by the server when building a page.
|
20
|
+
#
|
21
|
+
# The server sends:
|
22
|
+
# - ID:OUT:XXXXXXX where the contents of XXXX is new content in
|
23
|
+
# the stdOUT of the command that's associated with ID.
|
24
|
+
# - ID:ERR:XXXXXXX where the contents of XXXX is new content in
|
25
|
+
# the stdERR of the command that's associated with ID.
|
26
|
+
# - ID:FINISHED tells the client that the command associated with
|
27
|
+
# ID has finished running.
|
28
|
+
# - ID:CLEAR tells the client that the command associated with ID
|
29
|
+
# wants to clear the current output. This is useful when
|
30
|
+
# handling X-term escape sequences (eg. `top` wants a fresh page)
|
31
|
+
# The client sends:
|
32
|
+
# - ID:RUN requests that the server start running the command
|
33
|
+
# associated with ID.
|
34
|
+
# - ID:STOP requests that the server stop running the command
|
35
|
+
# associated with ID.
|
36
|
+
#
|
37
|
+
# 2. Dashboard Information
|
38
|
+
# These messages let the client indicate what information it wants to
|
39
|
+
# handle, and let the server send that content to the client either
|
40
|
+
# as a blob (if the information is historical) or as a stream (if
|
41
|
+
# the information is real time). When the client requests data, it
|
42
|
+
# includes a DID. The difference between the DID and an ID that
|
43
|
+
# is associated with a command is that the DID contains a letter
|
44
|
+
# at the start (eg. A12).
|
45
|
+
#
|
46
|
+
# DIDs are structured as a letter that represents a dashboard, and
|
47
|
+
# a number that represents the element on the dashboard.
|
48
|
+
#
|
49
|
+
# The server sends:
|
50
|
+
# - DID:Y:XXXXXXXX sends data that should be connected to the
|
51
|
+
# DID. The Y is either an A (representing "ALL") to say that the
|
52
|
+
# message is a self-contained data point, or a ratio like (1/2)
|
53
|
+
# to say its the first of two messages that when combined form
|
54
|
+
# a data point.
|
55
|
+
#
|
56
|
+
# At this point, the client does not send anything related to DIDs.
|
57
|
+
# Options in the future include letting the client request a range of
|
58
|
+
# data, but that will likely wait until the data on the server end
|
59
|
+
# is moved out of memory an on to disk.
|
60
|
+
|
12
61
|
|
13
62
|
|
14
63
|
# Imports
|
15
|
-
require 'digest'
|
16
|
-
require 'open3'
|
64
|
+
require 'digest' # Allows hashing of websocket authentication value
|
65
|
+
require 'open3' # Allows capturing stdout and stderr of system commands
|
66
|
+
require 'thread' # Allows use of mutexes.
|
67
|
+
|
68
|
+
require_relative 'data_gathering'
|
17
69
|
|
18
70
|
# Key Variables
|
19
|
-
MAX_WS_FRAME_SIZE = 50.0
|
71
|
+
MAX_WS_FRAME_SIZE = 50.0 # Must be a float number to allow a non-truncated division result.
|
20
72
|
|
21
73
|
# run_websocket
|
22
74
|
#
|
@@ -25,6 +77,15 @@ def run_websocket(socket, initial_request)
|
|
25
77
|
|
26
78
|
accept_connection(socket, initial_request[:fields][:Sec_WebSocket_Key][0..-3])
|
27
79
|
child_threads = {}
|
80
|
+
send_msg_mutex = Mutex.new # One mutex per websocket to control sending messages.
|
81
|
+
|
82
|
+
Thread.new { # Periodically update the generic dashboard if set.
|
83
|
+
loop do
|
84
|
+
data = read_data
|
85
|
+
send_msg(socket, send_msg_mutex, nil, "DASH", data)
|
86
|
+
sleep $config[:collect_data_period]
|
87
|
+
end
|
88
|
+
} unless $config[:collect_data_period] == 0
|
28
89
|
|
29
90
|
loop {
|
30
91
|
msg = receive_msg socket
|
@@ -36,10 +97,11 @@ def run_websocket(socket, initial_request)
|
|
36
97
|
|
37
98
|
unless child_threads[cmd_id]
|
38
99
|
child_threads[cmd_id] = Thread.new do
|
39
|
-
run_command_and_stream(socket, cmd_id)
|
100
|
+
run_command_and_stream(socket, cmd_id, send_msg_mutex)
|
40
101
|
child_threads[cmd_id] = nil
|
41
102
|
end
|
42
103
|
end
|
104
|
+
|
43
105
|
when "STOP"
|
44
106
|
|
45
107
|
cmd_id = msg.match(/^STOP:(.*)$/)[1].to_i
|
@@ -59,7 +121,7 @@ end
|
|
59
121
|
# run_command_and_stream
|
60
122
|
#
|
61
123
|
# Run a command and stream the stdout and stderr through the websocket.
|
62
|
-
def run_command_and_stream(socket, cmd_id)
|
124
|
+
def run_command_and_stream(socket, cmd_id, send_msg_mutex)
|
63
125
|
|
64
126
|
cmd = get_command cmd_id
|
65
127
|
if cmd.nil?
|
@@ -78,9 +140,9 @@ def run_command_and_stream(socket, cmd_id)
|
|
78
140
|
break
|
79
141
|
end
|
80
142
|
if fd == stdout
|
81
|
-
send_msg(socket, cmd_id, "OUT", resp
|
143
|
+
send_msg(socket, send_msg_mutex, cmd_id, "OUT", resp)
|
82
144
|
elsif fd == stderr
|
83
|
-
send_msg(socket, cmd_id, "ERR", resp
|
145
|
+
send_msg(socket, send_msg_mutex, cmd_id, "ERR", resp)
|
84
146
|
else
|
85
147
|
raise "Received output from popen3(#{cmd}) that was not via stdout or stderr."
|
86
148
|
end
|
@@ -89,7 +151,7 @@ def run_command_and_stream(socket, cmd_id)
|
|
89
151
|
end
|
90
152
|
|
91
153
|
cmd_thread.join
|
92
|
-
send_msg(socket, cmd_id, "FINISHED")
|
154
|
+
send_msg(socket, send_msg_mutex, cmd_id, "FINISHED")
|
93
155
|
end
|
94
156
|
end
|
95
157
|
|
@@ -161,47 +223,91 @@ end
|
|
161
223
|
# send_msg
|
162
224
|
#
|
163
225
|
#
|
164
|
-
def send_msg(socket, cmd_id, msg_type, msg=nil)
|
226
|
+
def send_msg(socket, send_msg_mutex, cmd_id, msg_type, msg=nil)
|
165
227
|
|
166
228
|
|
167
|
-
# TODO: Figure out the proper frame size (MAX_WS_FRAME_SIZE).
|
168
|
-
def send_frame(socket, fmsg)
|
169
|
-
output = [0b10000001, fmsg.size, fmsg]
|
170
|
-
socket.write output.pack("CCA#{fmsg.size}")
|
171
|
-
end
|
172
|
-
|
173
229
|
case msg_type
|
174
|
-
when "OUT", "ERR"
|
230
|
+
when "OUT", "ERR" # See comments at the top of the file to explain this part of the protocol.
|
175
231
|
header = "#{cmd_id}:#{msg_type}:"
|
176
232
|
if header.length > MAX_WS_FRAME_SIZE
|
177
233
|
log_error "Message header '#{msg_type}' is too long. Msg: #{msg}."
|
178
234
|
elsif msg.nil?
|
179
235
|
log_error "Message of type '#{msg_type}' sent without a message."
|
180
236
|
else
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
msg
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
237
|
+
send_msg_mutex.synchronize {
|
238
|
+
if msg.length > MAX_WS_FRAME_SIZE - header.length
|
239
|
+
msg_part_len = MAX_WS_FRAME_SIZE - header.length
|
240
|
+
msg_parts = (0..(msg.length-1)/msg_part_len).map{ |i|
|
241
|
+
msg[i*msg_part_len,msg_part_len]
|
242
|
+
}
|
243
|
+
msg_parts.each{ |part|
|
244
|
+
send_frame(socket, header + part)
|
245
|
+
}
|
246
|
+
else
|
247
|
+
send_frame(socket, header + msg)
|
248
|
+
end
|
249
|
+
}
|
192
250
|
end
|
193
251
|
|
252
|
+
when "DASH" # See comments at the top of the file to explain this part of the protocol.
|
253
|
+
if msg.nil?
|
254
|
+
log_error "Message of type '#{msg_type}' sent without a message."
|
255
|
+
end
|
256
|
+
msg.each_key { |dash_id|
|
257
|
+
msg[dash_id].each_key { |ele_index|
|
258
|
+
did = "#{dash_id}#{ele_index}"
|
259
|
+
send_msg_mutex.synchronize {
|
260
|
+
msg[dash_id][ele_index].each_key { |key|
|
261
|
+
data_fragment = "#{key}->#{msg[dash_id][ele_index][key]}"
|
262
|
+
if data_fragment.length > MAX_WS_FRAME_SIZE - (did.length + 3)
|
263
|
+
msg_part_len = MAX_WS_FRAME_SIZE - (did.length + 7) # TODO: Handle case where header is longer than DID:XX/XX:
|
264
|
+
msg_parts = (0..(data_fragment.length-1)/msg_part_len).map{ |i|
|
265
|
+
data_fragment[i*msg_part_len,msg_part_len]
|
266
|
+
}
|
267
|
+
msg_parts.each_with_index{ |part, index|
|
268
|
+
header = did + ":#{index + 1}/#{msg_parts.length}:"
|
269
|
+
if header.length > MAX_WS_FRAME_SIZE
|
270
|
+
log_error "Message header '#{msg_type}' is too long. Data: #{data_fragment}."
|
271
|
+
end
|
272
|
+
send_frame(socket, header + part)
|
273
|
+
}
|
274
|
+
else
|
275
|
+
send_frame(socket, did + ":A:" + data_fragment)
|
276
|
+
end
|
277
|
+
}
|
278
|
+
}
|
279
|
+
}
|
280
|
+
}
|
281
|
+
|
194
282
|
when "CLEAR", "FINISHED"
|
195
|
-
|
196
|
-
|
197
|
-
log_error "Message of type '#{msg_type}' is too long. Msg: #{to_send}."
|
198
|
-
elsif !msg.nil?
|
199
|
-
log_error "Message of type '#{msg_type}' passed a message. Msg: #{msg}."
|
200
|
-
else
|
201
|
-
send_frame(socket, to_send)
|
283
|
+
if !msg.nil?
|
284
|
+
log_error "Message of type '#{msg_type}' passed an empty message. Msg: #{msg}."
|
202
285
|
end
|
286
|
+
to_send = "#{cmd_id}:#{msg_type}"
|
287
|
+
send_msg_mutex.synchronize {
|
288
|
+
if to_send.length > MAX_WS_FRAME_SIZE
|
289
|
+
log_error "Message of type '#{msg_type}' is too long. Msg: #{to_send}."
|
290
|
+
else
|
291
|
+
send_frame(socket, to_send)
|
292
|
+
end
|
293
|
+
}
|
203
294
|
else
|
204
295
|
log_error "Trying to send a websocket message with unrecognized type: #{msg_type}"
|
205
296
|
end
|
206
297
|
|
207
298
|
end
|
299
|
+
|
300
|
+
# send_frame
|
301
|
+
#
|
302
|
+
# Sends a message over the websocket. Requires that the message be an appropriate
|
303
|
+
# length, and have the right format (eg. checks should be done before calling
|
304
|
+
# this function).
|
305
|
+
# TODO: Figure out the proper frame size (MAX_WS_FRAME_SIZE).
|
306
|
+
def send_frame(socket, msg)
|
307
|
+
output = [0b10000001, msg.size, msg]
|
308
|
+
begin
|
309
|
+
socket.write output.pack("CCA#{msg.size}")
|
310
|
+
rescue IOError
|
311
|
+
log_error "WebSocket is closed. Msg: #{msg}"
|
312
|
+
end
|
313
|
+
end
|