easel-dashboard 0.4.2
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|