easel-dashboard 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e0a7fbceaa1d9f616dd3a373aa892d371b8d4a77df06d66cb91734a4e581055f
4
+ data.tar.gz: 38cbd403c831aff841ba9e1a2bb6a10581f938f6491775123de3a6bcbf8b47d3
5
+ SHA512:
6
+ metadata.gz: 94f4e6a90899dc3c06817fc6eb4ce10484ebc58d3f3b9d79009167aca58701878c05ba3d1a9715d305196cb5ea76d9eeacfe9b7d05c61c3bf4e3b3c929474d83
7
+ data.tar.gz: 9f6af45a6fc1c55092e680fdd5a5a1b1e93801d8db6ee895ea77e7fc387098a619eea603f8cee7ce3d32b7ddd5ae4dda8f20a647ebac28734fa87ce5f4657a5e
data/bin/easel ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ require 'easel-dashboard'
3
+
4
+ launch
@@ -0,0 +1,56 @@
1
+ #!/snap/bin/ruby
2
+ #
3
+ # Author: Eric Power
4
+
5
+ # Imports
6
+ require 'erb'
7
+
8
+ # Key Variables
9
+ @code_names = {
10
+ 200 => "OK",
11
+ 404 => "Not Found",
12
+ 405 => "Forbidden",
13
+ 418 => "I'm a teapot",
14
+ 500 => "Internal Server Error"
15
+ }
16
+
17
+ # build_app
18
+ #
19
+ #
20
+ def build_app
21
+ app_erb = File.new("#{File.dirname(__FILE__)}/../html/app.html.erb").read
22
+ page = ERB.new(app_erb).result()
23
+
24
+ "HTTP/1.1 200 OK\r\n" +
25
+ "Content-Type: text/html; charset=UTF-8\r\n" +
26
+ "Content-Length: #{page.bytesize}\r\n" +
27
+ "Connection: close\r\n" +
28
+ "\r\n" +
29
+ page
30
+ end
31
+
32
+
33
+ def build_error code
34
+ error_erb = File.new("#{File.dirname(__FILE__)}/../html/error.html.erb").read
35
+ page = ERB.new(error_erb).result(binding)
36
+
37
+ "HTTP/1.1 #{code} #{@code_names[code]}\r\n" +
38
+ "Content-Type: text/html; charset=UTF-8\r\n" +
39
+ "Content-Length: #{page.bytesize}\r\n" +
40
+ "Connection: close\r\n" +
41
+ "\r\n" +
42
+ page
43
+ end
44
+
45
+
46
+ def build_css
47
+ error_erb = File.new("#{File.dirname(__FILE__)}/../html/app.css.erb").read
48
+ css = ERB.new(error_erb).result(binding)
49
+
50
+ "HTTP/1.1 200 OK\r\n" +
51
+ "Content-Type: text/css; charset=UTF-8\r\n" +
52
+ "Content-Length: #{css.bytesize}\r\n" +
53
+ "Connection: close\r\n" +
54
+ "\r\n" +
55
+ css
56
+ end
@@ -0,0 +1,43 @@
1
+ #!/snap/bin/ruby
2
+ #
3
+ # Author: Eric Power
4
+ #
5
+ # Description:
6
+ # This file contains the global variable $config. These values are only
7
+ # defaults. If the YAML file passed to launch.rb contains the same keys,
8
+ # then these values are overwritten.
9
+
10
+
11
+ # Global Variables
12
+ $config = {
13
+ logging: 2, # 0=Fatal, 1=Error, 2=Warning, 3=Info
14
+ port: 4200, # Default port
15
+ hostname: 'localhost', # Default hostname
16
+ log_file: STDOUT, # Default logging to STDOUT
17
+ title: 'Easel - Your Custom Dashboard',
18
+ colours: {
19
+ surface: '#222222',
20
+ background: '#000000',
21
+ primary: '#7DF9FF',
22
+ secondary: '#00FF00',
23
+ on_surface: '#ffffff',
24
+ on_background: '#ffffff',
25
+ on_primary: '#000000',
26
+ on_secondary: '#000000',
27
+ shadow: '#000000',
28
+ stdout_colour: '#ffffff',
29
+ stderr_colour: '#00FF00'
30
+ },
31
+ commands: [
32
+ {
33
+ name: 'Test 1',
34
+ cmd: 'echo "this is the output of Test 1"',
35
+ desc: 'Simple output test #1'
36
+ },
37
+ {
38
+ name: 'Test 2',
39
+ cmd: 'echo "this is the output of Test 2"',
40
+ desc: 'Simple output test #2'
41
+ }
42
+ ]
43
+ }
@@ -0,0 +1,35 @@
1
+ #!/snap/bin/ruby
2
+ #
3
+ # Author: Eric Power
4
+ #
5
+ # Description:
6
+ # Provides the functions to provide easy logging for the Easel Dashboard.
7
+
8
+ # Imports
9
+ require 'time'
10
+
11
+
12
+ def log_fatal *msg
13
+ unless $config[:logging] == 0
14
+ $config[:log_file].puts "[#{Time.new.strftime("%Y-%m-%d-%H:%M:%S")}] FATAL: " + msg.join(" ")
15
+ end
16
+ exit 1
17
+ end
18
+
19
+ def log_error *msg
20
+ unless $config[:logging] < 1
21
+ $config[:log_file].puts "[#{Time.new.strftime("%Y-%m-%d-%H:%M:%S")}] ERROR: " + msg.join(" ")
22
+ end
23
+ end
24
+
25
+ def log_warning *msg
26
+ unless $config[:logging] < 2
27
+ $config[:log_file].puts "[#{Time.new.strftime("%Y-%m-%d-%H:%M:%S")}] WARNING: " + msg.join(" ")
28
+ end
29
+ end
30
+
31
+ def log_info *msg
32
+ unless $config[:logging] < 3
33
+ $config[:log_file].puts "[#{Time.new.strftime("%Y-%m-%d-%H:%M:%S")}] INFO: " + msg.join(" ")
34
+ end
35
+ end
@@ -0,0 +1,114 @@
1
+ #!/snap/bin/ruby
2
+ #
3
+ # Author: Eric Power
4
+ #
5
+ # Description:
6
+ # Provides the server that serves Easel Dashboard.
7
+
8
+ # Imports
9
+ require 'socket'
10
+ require_relative './build_pages.rb'
11
+ require_relative './websocket'
12
+
13
+
14
+ def launch_server
15
+
16
+ # Lauch the TCPServer
17
+ begin
18
+ server = TCPServer.new($config[:hostname], $config[:port])
19
+ rescue Exception => e
20
+ log_fatal "Server could not start. Error message: #{e}"
21
+ end
22
+
23
+ Thread.abort_on_exception = true
24
+
25
+ # Main Loop
26
+ begin
27
+ loop {
28
+ Thread.start(server.accept) do |client|
29
+ handle_request client
30
+ end
31
+ }
32
+
33
+ # Handle shutting down.
34
+ rescue Interrupt
35
+ log_info "Interrupt received, server shutting down..."
36
+ end
37
+ end
38
+
39
+
40
+ def handle_request socket
41
+
42
+ log_info "Receieved request: #{socket}"
43
+ request = read_HTTP_message socket
44
+
45
+ # TODO: check what the minimum allow handling is. I think there's one more method I need to handle.
46
+ case request[:method]
47
+ when "GET"
48
+ # TODO: respond with app, css file, favicon, or 404 error.
49
+ if request[:fields][:Upgrade] == "websocket\r\n"
50
+ run_websocket(socket, request)
51
+ else
52
+ handle_get(socket, request)
53
+ end
54
+ #when "HEAD"
55
+ # TODO: Deal with HEAD request. https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4
56
+ else
57
+ # TODO: respond with an appropriate error.
58
+ response "I don't understand what you sent - go away."
59
+ socket.print "HTTP/1.1 200 OK\r\n" +
60
+ "Content-Type: text/plain\r\n" +
61
+ "Content-Length: #{response.bytesize}\r\n" +
62
+ "Connection: close\r\n" +
63
+ "\r\n" +
64
+ response
65
+ socket.close
66
+ end
67
+
68
+ end
69
+
70
+
71
+
72
+ # handle_get
73
+ #
74
+ # Handle a get request.
75
+ def handle_get(socket, request)
76
+
77
+ case request[:url]
78
+ when "/", "/index.html"
79
+ socket.print build_app
80
+ socket.close
81
+ when "/app.css"
82
+ socket.print build_css
83
+ socket.close
84
+ else
85
+ socket.print build_error 404
86
+ socket.close
87
+ end
88
+
89
+ end
90
+
91
+
92
+ # read_HTTP_message
93
+ #
94
+ # Read an HTTP message from the socket, and parse it into a request Hash.
95
+ def read_HTTP_message socket
96
+ message = []
97
+ loop do
98
+ line = socket.gets
99
+ message << line
100
+ if line == "\r\n"
101
+ break
102
+ end
103
+ end
104
+
105
+
106
+ request = {fields: {}}
107
+ (request[:method], request[:url], request[:protocol]) = message[0].split(" ")
108
+
109
+ message[1..-1].each{ |line|
110
+ (key, value) = line.split(": ")
111
+ request[:fields][key.split("-").join("_").to_sym] = value
112
+ }
113
+ request
114
+ end
@@ -0,0 +1,207 @@
1
+ #!/snap/bin/ruby
2
+ #
3
+ # Author: Eric Power
4
+ #
5
+ # Description:
6
+ # Contains the functions that control the websocket. The WebSocket protocol
7
+ # that I'm implementing/creating has a max frame size of MAX_WS_FRAME_SIZE
8
+ # bytes.
9
+ #
10
+ # Note: Websocket code is based on the code provided in the following article:
11
+ # https://www.honeybadger.io/blog/building-a-simple-websockets-server-from-scratch-in-ruby/
12
+
13
+
14
+ # Imports
15
+ require 'digest'
16
+ require 'open3'
17
+
18
+ # Key Variables
19
+ MAX_WS_FRAME_SIZE = 50.0
20
+
21
+ # run_websocket
22
+ #
23
+ #
24
+ def run_websocket(socket, initial_request)
25
+
26
+ accept_connection(socket, initial_request[:fields][:Sec_WebSocket_Key][0..-3])
27
+ child_threads = {}
28
+
29
+ loop {
30
+ msg = receive_msg socket
31
+ break if msg.nil? # The socket was closed by the client.
32
+
33
+ case msg.split(":")[0]
34
+ when "RUN"
35
+ cmd_id = msg.match(/^RUN:(.*)$/)[1].to_i
36
+
37
+ unless child_threads[cmd_id]
38
+ child_threads[cmd_id] = Thread.new do
39
+ run_command_and_stream(socket, cmd_id)
40
+ child_threads[cmd_id] = nil
41
+ end
42
+ end
43
+ when "STOP"
44
+
45
+ cmd_id = msg.match(/^STOP:(.*)$/)[1].to_i
46
+ unless child_threads[cmd_id].nil?
47
+ child_threads[cmd_id].kill
48
+ child_threads[cmd_id] = nil
49
+ end
50
+
51
+ else
52
+ log_error "Received an unrecognized message over the websocket: #{msg}"
53
+ end
54
+ }
55
+
56
+ end
57
+
58
+
59
+ # run_command_and_stream
60
+ #
61
+ # Run a command and stream the stdout and stderr through the websocket.
62
+ def run_command_and_stream(socket, cmd_id)
63
+
64
+ cmd = get_command cmd_id
65
+ if cmd.nil?
66
+ log_error "Client requested command ID #{cmd_id} be run, but that ID does not exist."
67
+ return
68
+ end
69
+ Open3::popen3(cmd) do |stdin, stdout, stderr, cmd_thread|
70
+
71
+ continue = true
72
+
73
+ while ready_fds = IO.select([stdout, stderr])[0]
74
+ ready_fds.each{ |fd|
75
+ resp = fd.gets
76
+ if resp.nil?
77
+ continue = false
78
+ break
79
+ end
80
+ if fd == stdout
81
+ send_msg(socket, cmd_id, "OUT", resp, )
82
+ elsif fd == stderr
83
+ send_msg(socket, cmd_id, "ERR", resp, )
84
+ else
85
+ raise "Received output from popen3(#{cmd}) that was not via stdout or stderr."
86
+ end
87
+ }
88
+ break unless continue
89
+ end
90
+
91
+ cmd_thread.join
92
+ send_msg(socket, cmd_id, "FINISHED")
93
+ end
94
+ end
95
+
96
+
97
+
98
+ # get_command
99
+ #
100
+ #
101
+ def get_command cmd_id
102
+ $config[:commands].each { |cmd|
103
+ if cmd[:id] == cmd_id
104
+ return cmd[:cmd]
105
+ end
106
+ }
107
+ nil
108
+ end
109
+
110
+ # accept_connection
111
+ #
112
+ # Sends back the HTTP header to initialize the websocket connection.
113
+ def accept_connection(socket, ws_key)
114
+
115
+ ws_accept_key = Digest::SHA1.base64digest(
116
+ ws_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")
117
+ socket.write "HTTP/1.1 101 Switching Protocols\r\n" +
118
+ "Upgrade: websocket\r\n" +
119
+ "Connection: Upgrade\r\n" +
120
+ "Sec-WebSocket-Accept: #{ws_accept_key}\r\n" +
121
+ "\r\n"
122
+ end
123
+
124
+
125
+ # receive_msg
126
+ #
127
+ #
128
+ def receive_msg socket
129
+
130
+ # Check first two bytes
131
+ byte1 = socket.getbyte
132
+ byte2 = socket.getbyte
133
+ if byte1 == 0x88 # Client is requesting that we close the connection.
134
+ # TODO: Unsure how to properly handle this case. Right now the socket will close and
135
+ # everything here will shut down - eventually? Kill all child threads first?
136
+ log_info "Client requested the websocket be closed."
137
+ socket.close
138
+ return
139
+ end
140
+ fin = byte1 & 0b10000000
141
+ opcode = byte1 & 0b00001111
142
+ msg_size = byte2 & 0b01111111
143
+ is_masked = byte2 & 0b10000000
144
+ unless fin and opcode == 1 and is_masked and msg_size < MAX_WS_FRAME_SIZE
145
+ log_error "Invalid websocket message received. #{byte1}-#{byte2}"
146
+ puts socket.gets
147
+ msg_size.times.map { socket.getbyte } # Read message from socket.
148
+ return
149
+ end
150
+
151
+ # Get message
152
+ mask = 4.times.map { socket.getbyte }
153
+ msg = msg_size.times.map { socket.getbyte }.each_with_index.map {
154
+ |byte, i| byte ^ mask[i % 4]
155
+ }.pack('C*').force_encoding('utf-8').inspect
156
+ msg[1..-2] # Remove quotation marks from message
157
+
158
+ end
159
+
160
+
161
+ # send_msg
162
+ #
163
+ #
164
+ def send_msg(socket, cmd_id, msg_type, msg=nil)
165
+
166
+
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
+ case msg_type
174
+ when "OUT", "ERR"
175
+ header = "#{cmd_id}:#{msg_type}:"
176
+ if header.length > MAX_WS_FRAME_SIZE
177
+ log_error "Message header '#{msg_type}' is too long. Msg: #{msg}."
178
+ elsif msg.nil?
179
+ log_error "Message of type '#{msg_type}' sent without a message."
180
+ else
181
+ if msg.length > MAX_WS_FRAME_SIZE - header.length
182
+ msg_part_len = MAX_WS_FRAME_SIZE - header.length
183
+ msg_parts = (0..(msg.length-1)/msg_part_len).map{ |i|
184
+ msg[i*msg_part_len,msg_part_len]
185
+ }
186
+ msg_parts.each{ |part|
187
+ send_frame(socket, header + part)
188
+ }
189
+ else
190
+ send_frame(socket, header + msg)
191
+ end
192
+ end
193
+
194
+ when "CLEAR", "FINISHED"
195
+ to_send = "#{cmd_id}:#{msg_type}"
196
+ if to_send.length > MAX_WS_FRAME_SIZE
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)
202
+ end
203
+ else
204
+ log_error "Trying to send a websocket message with unrecognized type: #{msg_type}"
205
+ end
206
+
207
+ end
data/lib/easel.rb ADDED
@@ -0,0 +1,135 @@
1
+ #!/snap/bin/ruby
2
+ #
3
+ # Author: Eric Power
4
+ #
5
+ # Description:
6
+ # Easel Dashboard turns a YAML file into a custom dashboard. See docs/configuration for
7
+ # a description of how to set up the YAML file.
8
+
9
+ # Imports
10
+ require 'optparse'
11
+ require 'yaml'
12
+
13
+ require_relative 'easel/logging'
14
+ require_relative 'easel/server'
15
+ require_relative 'easel/configuration'
16
+
17
+ # launch
18
+ #
19
+ # Launches the Easel Dashboard. Check the $config variable for defaults, although
20
+ # everything can be overridden by the YAML file (and some by the command line
21
+ # arguments).
22
+ def launch
23
+
24
+ parse_ARGV
25
+
26
+ # Load the provided YAML
27
+ overwrite_config $config[:yaml_file]
28
+ log_info("YAML loaded successfully (from: #{$config[:yaml_file]})")
29
+ $config[:commands].each_with_index{ |cmd, i| cmd[:id] = i } # Give commands an ID.
30
+ $config.freeze # Set config to read only
31
+
32
+ # Lauch the server
33
+ log_info("Launching server at #{$config[:hostname]}:#{$config[:port]}")
34
+ launch_server
35
+ end
36
+
37
+
38
+ # parse_ARGV
39
+ #
40
+ # Parses the command line arguments (ARGV) using the optparse gem. Optional
41
+ # command line arguments can be seen by running this program with the -h (or
42
+ # --help) flag.
43
+ def parse_ARGV
44
+ opt_parser = OptionParser.new do |opts|
45
+ opts.banner = "Useage: launch.rb [flags] configuration.yaml"
46
+
47
+ opts.on("-h", "--help", "Prints this help message.") do
48
+ puts opts
49
+ exit
50
+ end
51
+
52
+ opts.on("-l LOG_LEVEL", "--log LOG_LEVEL", Integer, "Sets the logging level (default=2). 0=Silent, 1=Errors, 2=Warnings, 3=Info.") do |lvl|
53
+ if [0, 1, 2, 3].include?(lvl)
54
+ $config[:logging] = lvl
55
+ else
56
+ log_fatal "Command argument LOG_LEVEL '#{lvl}' not recognized. Expected 0, 1, 2, or 3."
57
+ end
58
+ end
59
+
60
+ opts.on("-p PORT", "--port PORT", Integer, "Sets the port to bind to. Default is #{$config[:port]}.") do |port|
61
+ if port >= 0 and port <= 65535
62
+ $config[:port] = port
63
+ else
64
+ log_fatal "Command argument PORT '#{port}' not a valid port. Must be between 0 and 65535 (inclusive)"
65
+ end
66
+ end
67
+
68
+ opts.on("-h HOST", "--hostname HOST", "Sets the hostname. Default is '#{$config[:hostname]}'.") do |port|
69
+ if port >= 0 and port <= 65535
70
+ $config[:port] = port
71
+ else
72
+ log_fatal "Command argument PORT '#{port}' not a valid port. Must be between 0 and 65535 (inclusive)"
73
+ end
74
+ end
75
+
76
+ opts.on("-o [FILE]", "--output [FILE]", "Set a log file.") do |filename|
77
+ begin
78
+ $config[:log_file] = File.new(filename, "a")
79
+ rescue Exception => e
80
+ log_error "Log file could not be open. Sending log to STDIN. Error message: #{e}"
81
+ end
82
+ end
83
+ end.parse!
84
+
85
+ if ARGV.length != 1
86
+ log_fatal "launch.rb takes exactly one file. Try -h for more details."
87
+ else
88
+ $config[:yaml_file] = ARGV[0]
89
+ end
90
+ end
91
+
92
+
93
+ # overwrite_config
94
+ #
95
+ # Overwrites the $config fields that are set in the YAML file provided on input.
96
+ # TODO: Log (error?) every key in the YAML file that does not exist in $config.
97
+ def overwrite_config yaml_filename
98
+
99
+ # TODO: Rewrite using pattern matching to allow checking if the
100
+ # yaml_contents.each is one of the base keys. If so, check that the associated
101
+ # value matches the expected nesting. Do that 'no other values' check.
102
+
103
+ # TODO: Ensure that the command names are less than 1020 bytes (because I'm
104
+ # setting the max length of a single websocket message to 1024 (minus 'STOP:'))
105
+
106
+ begin
107
+ yaml_contents = YAML.load_file $config[:yaml_file]
108
+ rescue Exception => e
109
+ log_fatal "YAML failed to load. Error Message: #{e}"
110
+ end
111
+
112
+ def loop_overwrite (config, yaml)
113
+ yaml.each_key { |key|
114
+ if yaml[key].is_a? Hash
115
+ loop_overwrite(config[key.to_sym], yaml[key])
116
+ elsif yaml[key].is_a? Array
117
+ config[key.to_sym] = []
118
+ yaml[key].each { |elmnt|
119
+ element = {}
120
+ loop_overwrite(element, elmnt)
121
+ config[key.to_sym] << element
122
+ }
123
+ else
124
+ config[key.to_sym] = yaml[key]
125
+ end
126
+ }
127
+ end
128
+
129
+ loop_overwrite($config, yaml_contents)
130
+ end
131
+
132
+
133
+ if __FILE__ == $0
134
+ launch
135
+ end
@@ -0,0 +1,204 @@
1
+ html, body {
2
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
3
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
4
+ sans-serif;
5
+ -webkit-font-smoothing: antialiased;
6
+ -moz-osx-font-smoothing: grayscale;
7
+
8
+ --size-tiny: 5px;
9
+ --size-small: 10px;
10
+ --size-medium: 20px;
11
+ --size-large: 40px;
12
+ --size-xlarge: 80px;
13
+
14
+ --surface: <%= $config[:colours][:surface] %>;
15
+ --background: <%= $config[:colours][:background] %>;
16
+ --primary: <%= $config[:colours][:primary] %>;
17
+ --secondary: <%= $config[:colours][:secondary] %>;
18
+ --on-background: <%= $config[:colours][:on_background] %>;
19
+ --on-surface: <%= $config[:colours][:on_surface] %>;
20
+ --on-primary: <%= $config[:colours][:on_primary] %>;
21
+ --on-secondary: <%= $config[:colours][:on_secondary] %>;
22
+ --shadow: <%= $config[:colours][:shadow] %>;
23
+ --stdout-colour: <%= $config[:colours][:stdout_colour] %>;
24
+ --stderr-colour: <%= $config[:colours][:stderr_colour] %>;
25
+
26
+
27
+ --text-size-small: 12pt;
28
+ --text-size: 14pt;
29
+ --text-size-large: 18pt;
30
+ --text-size-xlarge: 28pt;
31
+
32
+ --text-weight: 300;
33
+ --text-weight-semi-bold:450;
34
+ --text-weight-bold: 600;
35
+
36
+ background-color: var(--background);
37
+ color: var(--on-background);
38
+
39
+ font-size: var(--text-size);
40
+ font-weight: var(--text-weight);
41
+ line-height: 1;
42
+
43
+ margin: 0;
44
+ box-sizing: border-box;
45
+ }
46
+ span.stderr { color: var(--stderr-colour); }
47
+ span.stdout { color: var(--stdout-colour); }
48
+
49
+ h1, .h1 {font-size: var(--text-size-xlarge); font-weight: var(--text-weight-bold);}
50
+ h2, .h2 {font-size: var(--text-size-large); font-weight: var(--text-weight-bold);}
51
+ h3, .h3 {font-size: var(--text-size-large); font-weight: var(--text-weight-semi-bold);}
52
+ h4, .h4 {font-size: var(--text-size); font-weight: var(--text-weight-semi-bold);}
53
+
54
+ *{padding: 0; margin: 0;}
55
+
56
+
57
+
58
+ .screen {
59
+ height: 100vh;
60
+ width: 100vw;
61
+ display: flex;
62
+ flex-direction: row;
63
+ justify-content: stretch;
64
+ align-items: stretch;
65
+ }
66
+
67
+ .cmd-picker {
68
+ width: 320px;
69
+ height: 100%;
70
+ overflow-y: scroll;
71
+ }
72
+ .cmd-picker .command:not(:last-child) {border-bottom: solid 1px var(--accent);}
73
+
74
+ .command-box{
75
+ display: flex;
76
+ flex-direction: column;
77
+ background-color: var(--surface);
78
+ border-radius: var(--size-small);
79
+ align-items: stretch;
80
+ margin: var(--size-small);
81
+ overflow: hidden;
82
+ }
83
+
84
+ .command-info {
85
+ background-color: var(--surface);
86
+ color: var(--on-surface);
87
+ padding: var(--size-medium);
88
+ padding-bottom: var(--size-small);
89
+ display: flex;
90
+ flex-direction: column;
91
+ align-items: flex-start;
92
+ border-bottom: solid 1px var(--primary);
93
+ z-index: 3;
94
+ box-shadow: 0 0 var(--size-small) var(--shadow);
95
+ cursor: pointer;
96
+ }
97
+ .command-info:hover { color: var(--primary); }
98
+
99
+ .command-controls {
100
+ display: flex;
101
+ flex-direction: row;
102
+ justify-content: stretch;
103
+ align-items: baseline;
104
+ box-shadow: inset 0 var(--size-tiny) var(--size-small) #000000;
105
+ }
106
+
107
+ .command-icon {
108
+ flex-grow: 2;
109
+ text-align: center;
110
+ padding: var(--size-tiny);
111
+ background-color: var(--primary);
112
+ color: var(--on-primary);
113
+ }
114
+ .command-icon:hover {
115
+ background-color: var(--secondary);
116
+ color: var(--on-secondary);
117
+ cursor: pointer;
118
+ }
119
+
120
+ ::-webkit-scrollbar {
121
+ width: var(--size-small);
122
+ height: var(--size-small);
123
+ background-color: var(--background);
124
+ }
125
+ ::-webkit-scrollbar-thumb {
126
+ background: var(--primary);
127
+ border-radius: var(--size-tiny);
128
+ border: solid 2px var(--background);
129
+ }
130
+
131
+
132
+
133
+
134
+
135
+
136
+
137
+
138
+
139
+ .interface {
140
+ /* margin: var(--size-large); */
141
+ flex-grow: 2;
142
+ display: flex;
143
+ flex-direction: column;
144
+ justify-content: stretch;
145
+ align-items: stretch;
146
+ overflow: hidden;
147
+ border-radius: var(--size-small);
148
+ padding: var(--size-medium);
149
+ margin: var(--size-small);
150
+ }
151
+ #interface-title {
152
+ padding-bottom: var(--size-medium);
153
+ }
154
+ .interface-output {
155
+ flex-grow: 2;
156
+ flex-basis: 0;
157
+ padding: var(--size-medium);
158
+ box-shadow: inset 0 0 var(--size-medium) var(--background);
159
+ }
160
+ .interface-footer{
161
+ display: flex;
162
+ flex-direction: row;
163
+ justify-content: flex-end;
164
+ padding-top: var(--size-medium);
165
+ }
166
+ .interface-commands {
167
+ width: 33%;
168
+ position: relative;
169
+ display: flex;
170
+ flex-direction: row;
171
+ justify-content: stretch;
172
+ border-radius: var(--size-small);
173
+ overflow: hidden;
174
+ }
175
+ .interface-button {
176
+ flex-grow: 2;
177
+ flex-basis: 0;
178
+ flex-shrink: 2;
179
+ text-align: center;
180
+ padding: var(--size-tiny) var(--size-small);
181
+ background-color: var(--primary);
182
+ color: var(--on-primary);
183
+ overflow: hidden;
184
+ }
185
+ .interface-button:hover {
186
+ background-color: var(--secondary);
187
+ color: var(--on-secondary);
188
+ cursor: pointer;
189
+ }
190
+ .top-shadow {
191
+ position: absolute;
192
+ width: 100%;
193
+ transform: translate(calc( -1 * var(--size-small)),calc( -1 * calc( var(--size-large))));
194
+ height: var(--size-large);
195
+ background-color: none;
196
+ z-index: 50;
197
+ box-shadow: 0 0 var(--size-small) var(--shadow);
198
+ }
199
+
200
+
201
+ .surface{ background-color: var(--surface); color: var(--on-surface); }
202
+ .background{ background-color: var(--background); color: var(--on-background); }
203
+
204
+ .scroll-content { min-height: min-content; overflow: auto; }
@@ -0,0 +1,181 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" dir="ltr">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>
6
+ <%= $config[:title] %>
7
+ </title>
8
+ <link rel="stylesheet" href="/app.css">
9
+ <script type="text/javascript">
10
+
11
+ // TODO: Add a RUN and STOP variable, that point to a div as a play button
12
+ // or a square.
13
+ let ws_socket = new WebSocket("ws://" + location.hostname + ":" + location.port);
14
+ let current_cmd = null;
15
+ let keep_pane_contents = false; // Remove default contents when you run a command.
16
+
17
+ let streams = { <%$config[:commands].each do |command|%>
18
+ <%= command[:id] %>: { is_running: false, content: "", name: "<%= command[:name] %>" },<% end %>
19
+ };
20
+
21
+ // Receive message
22
+ ws_socket.onmessage = (event) => {
23
+
24
+ pane = document.getElementById('output-pane');
25
+ console.log("Received: " + event.data);
26
+
27
+ // Clear pane if appropriate
28
+ if (!keep_pane_contents) {
29
+ pane.innerHTML = "";
30
+ keep_pane_contents = true;
31
+ }
32
+
33
+ // Split message into [ID, CMD, MSG]
34
+ msg_frags = event.data.split(":");
35
+ id = msg_frags[0];
36
+ cmd_type = msg_frags[1];
37
+ msg = msg_frags.slice(2).join(":");
38
+
39
+
40
+ // Validate
41
+ if (!(id in streams) || !(["ERR", "OUT", "CLEAR", "FINISHED"].includes(cmd_type)) ) {
42
+ console.log("Error validating message: " + events.data);
43
+ return;
44
+ }
45
+
46
+ // Process message
47
+ switch (cmd_type) {
48
+ case "ERR":
49
+ append_stream_content(id, "<span class=\"stderr\">" + msg + "</span>");
50
+ break;
51
+ case "OUT":
52
+ append_stream_content(id, msg);
53
+ break;
54
+ case "CLEAR":
55
+ streams[id]['content'] = "";
56
+ update_pane();
57
+ break;
58
+ case "FINISHED":
59
+ document.getElementById('cmd-' + id + '-run-icon').innerHTML = "Run";
60
+ streams[id]['is_running'] = false;
61
+ update_pane();
62
+ break;
63
+ default:
64
+ console.log("Error: Message not understood. Id: " + id + ", cmd_type: " + cmd_type + ", msg:" + msg);
65
+ }
66
+ }
67
+
68
+ // append_stream_content
69
+ function append_stream_content(id, content){
70
+ console.log("Id: " + id + ", current_cmd: " + current_cmd + ", content: " + content);
71
+ streams[id]['content'] += content;
72
+ update_pane();
73
+ }
74
+
75
+ function toggle_run(id){
76
+ if (streams[id]['is_running']) { // STOP (already running)
77
+ ws_socket.send("STOP:" + id);
78
+ document.getElementById('cmd-' + id + '-run-icon').innerHTML = "Run";
79
+ } else { // RUN (currently not running)
80
+ streams[id]['content'] = "";
81
+ ws_socket.send("RUN:" + id);
82
+ document.getElementById('cmd-' + id + '-run-icon').innerHTML = "Stop";
83
+ current_cmd = id;
84
+ }
85
+ streams[id]['is_running'] = !streams[id]['is_running'];
86
+ update_pane();
87
+ }
88
+
89
+ function load_pane(id) {
90
+ current_cmd = id;
91
+ update_pane();
92
+ }
93
+
94
+ function update_pane() {
95
+ pane = document.getElementById('output-pane');
96
+ pane_state = document.getElementById('interface-state');
97
+ pane_state_details = document.getElementById('interface-state-details');
98
+
99
+ pane.innerHTML = streams[current_cmd]['content'];
100
+ if (streams[current_cmd]['is_running']) {
101
+ pane_state.innerHTML = "Running: ";
102
+ } else {
103
+ pane_state.innerHTML = "Output Of: ";
104
+ }
105
+ pane_state_details.innerHTML = streams[current_cmd]['name'];
106
+ }
107
+
108
+ function show_help_message() {
109
+ current_cmd = null;
110
+ pane = document.getElementById('output-pane');
111
+ pane_state = document.getElementById('interface-state');
112
+ pane_state_details = document.getElementById('interface-state-details');
113
+
114
+
115
+ pane.innerHTML =
116
+ "Welcome to the CDash Dashboard! Programs are set up via a YAML file on the server.\n\n" +
117
+ "You can run these programs by clicking 'Run' under a program's information.\n\n" +
118
+ "Programs' output will update even if you are looking at the output of another program.\n\n" +
119
+ "Use the 'Show' button to select which programs' output you are seeing.";
120
+ pane_state.innerHTML = "Showing: ";
121
+ pane_state_details = "Help Message";
122
+ }
123
+
124
+ </script>
125
+ </head>
126
+ <body>
127
+ <div class="screen">
128
+ <div class="cmd-picker">
129
+
130
+ <% $config[:commands].each do |command| %>
131
+ <div class="command-box">
132
+ <div class="command-info"
133
+ onclick="load_pane(<%=command[:id]%>)"
134
+ id="cmd-<%=command[:id]%>"
135
+ data-id="<%=command[:id]%>"
136
+ data-cmd="<%=command[:cmd]%>">
137
+ <h2><%= command[:name] %></h2>
138
+ <p><%= command[:desc] %></p>
139
+ </div>
140
+ <div class="command-controls">
141
+ <div class="command-icon"
142
+ id="cmd-<%=command[:id]%>-run-icon"
143
+ onclick="toggle_run(<%= command[:id] %>)">
144
+ Run
145
+ </div>
146
+ <div class="command-icon"
147
+ id="cmd-<%=command[:id]%>-log-icon"
148
+ onclick="load_pane(<%=command[:id]%>)">
149
+ Show
150
+ </div>
151
+ </div>
152
+ </div>
153
+ <% end %>
154
+ </div>
155
+ <div class="interface surface">
156
+ <div id="interface-title">
157
+ <span class="h2" id="interface-state">Waiting:</span>
158
+ <span id="interface-state-details"></span>
159
+ </div>
160
+ <div class="interface-output scroll-content">
161
+ <pre class="output-pane" id="output-pane">Click 'Run' to run a command and see it's output.
162
+
163
+ Click 'Help' (below) to see more.</pre>
164
+ </div>
165
+ <div class="interface-footer">
166
+ <div class="interface-commands">
167
+ <div class="top-shadow"></div>
168
+ <div class="interface-button"
169
+ onclick="show_help_message()">
170
+ Help
171
+ </div>
172
+ <div class="interface-button"
173
+ onclick="console.log('Save output button has not been implemented.')">
174
+ Save Output
175
+ </div>
176
+ </div>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ </body>
181
+ </html>
@@ -0,0 +1,18 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" dir="ltr">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>
6
+ <%= "CDash - #{code} + Error" %>
7
+ </title>
8
+ <link rel="stylesheet" href="/app.css">
9
+ </head>
10
+ <body>
11
+ <h2>
12
+ <%= "#{code} + Error" %>
13
+ </h2>
14
+ <p>
15
+ <%= @code_names[404] %>
16
+ </p>
17
+ </body>
18
+ </html>
metadata ADDED
@@ -0,0 +1,56 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: easel-dashboard
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.2
5
+ platform: ruby
6
+ authors:
7
+ - Eric Power
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-09-16 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Use a YAML file to set up a dashboard, and change everything from the
14
+ page colours to the commands that can be run. The dashboard shows the output of
15
+ each command, which can be used to monitor server health, running processes, and
16
+ much more.
17
+ email: ericpower@outlook.com
18
+ executables:
19
+ - easel
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - bin/easel
24
+ - lib/easel.rb
25
+ - lib/easel/build_pages.rb
26
+ - lib/easel/configuration.rb
27
+ - lib/easel/logging.rb
28
+ - lib/easel/server.rb
29
+ - lib/easel/websocket.rb
30
+ - lib/html/app.css.erb
31
+ - lib/html/app.html.erb
32
+ - lib/html/error.html.erb
33
+ homepage: https://github.com/epwr/cdash
34
+ licenses:
35
+ - MIT
36
+ metadata: {}
37
+ post_install_message:
38
+ rdoc_options: []
39
+ require_paths:
40
+ - lib
41
+ required_ruby_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ required_rubygems_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '0'
51
+ requirements: []
52
+ rubygems_version: 3.2.22
53
+ signing_key:
54
+ specification_version: 4
55
+ summary: Easily set up and serve a dashboard using only a YAML file.
56
+ test_files: []