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 +7 -0
- data/bin/easel +4 -0
- data/lib/easel/build_pages.rb +56 -0
- data/lib/easel/configuration.rb +43 -0
- data/lib/easel/logging.rb +35 -0
- data/lib/easel/server.rb +114 -0
- data/lib/easel/websocket.rb +207 -0
- data/lib/easel.rb +135 -0
- data/lib/html/app.css.erb +204 -0
- data/lib/html/app.html.erb +181 -0
- data/lib/html/error.html.erb +18 -0
- metadata +56 -0
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,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
|
data/lib/easel/server.rb
ADDED
@@ -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: []
|