sillydev 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +277 -0
- data/exe/sillydev +6 -0
- data/lib/sillydev/cli.rb +295 -0
- data/lib/sillydev/client.rb +199 -0
- data/lib/sillydev/console.rb +1131 -0
- data/lib/sillydev/envelope.rb +84 -0
- data/lib/sillydev/errors.rb +160 -0
- data/lib/sillydev/mcp_server.rb +193 -0
- data/lib/sillydev/monitoring/aggregator.rb +107 -0
- data/lib/sillydev/monitoring/client.rb +674 -0
- data/lib/sillydev/monitoring/discordrb.rb +131 -0
- data/lib/sillydev/monitoring/http.rb +31 -0
- data/lib/sillydev/monitoring/rack.rb +114 -0
- data/lib/sillydev/monitoring/rails.rb +41 -0
- data/lib/sillydev/monitoring/runtime.rb +96 -0
- data/lib/sillydev/monitoring/serialize.rb +168 -0
- data/lib/sillydev/monitoring.rb +227 -0
- data/lib/sillydev/resources.rb +844 -0
- data/lib/sillydev/transport.rb +71 -0
- data/lib/sillydev/version.rb +5 -0
- data/lib/sillydev/websocket.rb +248 -0
- data/lib/sillydev.rb +20 -0
- metadata +67 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SillyDev
|
|
4
|
+
# Removing the panel's {"object", "attributes"} envelopes.
|
|
5
|
+
module Envelope
|
|
6
|
+
ITEM_KEYS = %w[object attributes meta].freeze
|
|
7
|
+
LIST_KEYS = %w[object data meta].freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Strips the envelopes from an API response, at every level.
|
|
12
|
+
#
|
|
13
|
+
# {"object" => "server", "attributes" => {...}} becomes the attributes,
|
|
14
|
+
# {"object" => "list", "data" => [...]} becomes the list of items, a map such as
|
|
15
|
+
# {"object" => "server_stats_list", "data" => {uuid => item}} becomes {uuid => attributes},
|
|
16
|
+
# and a null_resource (a relationship the key may not see) becomes nil.
|
|
17
|
+
def unwrap(value)
|
|
18
|
+
case value
|
|
19
|
+
when Array
|
|
20
|
+
value.map { |item| unwrap(item) }
|
|
21
|
+
when Hash
|
|
22
|
+
kind = value["object"]
|
|
23
|
+
if kind.is_a?(String)
|
|
24
|
+
return nil if kind == "null_resource"
|
|
25
|
+
return unwrap(value["attributes"]) if value.key?("attributes") && (value.keys - ITEM_KEYS).empty?
|
|
26
|
+
if value.key?("data") && (value.keys - LIST_KEYS).empty? && (value["data"].is_a?(Array) || value["data"].is_a?(Hash))
|
|
27
|
+
return unwrap(value["data"])
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
value.to_h { |key, item| [key, unwrap(item)] }
|
|
31
|
+
else
|
|
32
|
+
value
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# One page of a paginated list: the unwrapped items plus the list's meta.
|
|
38
|
+
class Page
|
|
39
|
+
include Enumerable
|
|
40
|
+
|
|
41
|
+
attr_reader :data, :meta
|
|
42
|
+
|
|
43
|
+
def self.from_response(body)
|
|
44
|
+
return new([], {}) unless body.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
meta = body["meta"].is_a?(Hash) ? Envelope.unwrap(body["meta"]) : {}
|
|
47
|
+
new(Array(Envelope.unwrap(body["data"] || [])), meta)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def initialize(data, meta = {})
|
|
51
|
+
@data = data
|
|
52
|
+
@meta = meta
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def pagination
|
|
56
|
+
meta["pagination"].is_a?(Hash) ? meta["pagination"] : {}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def current_page
|
|
60
|
+
(pagination["current_page"] || 1).to_i
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def total_pages
|
|
64
|
+
(pagination["total_pages"] || 1).to_i
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def total
|
|
68
|
+
pagination["total"]&.to_i
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def next_page?
|
|
72
|
+
!data.empty? && current_page < total_pages
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def each(&)
|
|
76
|
+
data.each(&)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def size
|
|
80
|
+
data.size
|
|
81
|
+
end
|
|
82
|
+
alias length size
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SillyDev
|
|
6
|
+
# Base class for every error this gem raises.
|
|
7
|
+
#
|
|
8
|
+
# Errors the API answers with come as {"errors": [{"code", "status", "detail"}]}: the message is
|
|
9
|
+
# the first detail and #errors holds the whole list.
|
|
10
|
+
class Error < StandardError
|
|
11
|
+
attr_reader :status_code, :errors, :response
|
|
12
|
+
|
|
13
|
+
def initialize(message = nil, status_code: nil, errors: nil, response: nil)
|
|
14
|
+
super(message)
|
|
15
|
+
@status_code = status_code
|
|
16
|
+
@errors = errors || []
|
|
17
|
+
@response = response
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The code of the first error, such as "NotFoundHttpException".
|
|
21
|
+
def code
|
|
22
|
+
first = @errors.first
|
|
23
|
+
first && !first["code"].nil? ? first["code"].to_s : nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The API could not be reached (DNS, TLS, connection refused or a timeout).
|
|
28
|
+
class APIConnectionError < Error; end
|
|
29
|
+
|
|
30
|
+
# The API answered with an error status that has no more specific class.
|
|
31
|
+
class APIStatusError < Error; end
|
|
32
|
+
|
|
33
|
+
# 400: the request could not be carried out as sent.
|
|
34
|
+
class BadRequestError < APIStatusError; end
|
|
35
|
+
|
|
36
|
+
# 401: no API key, or it is wrong, expired or deleted.
|
|
37
|
+
class AuthenticationError < APIStatusError; end
|
|
38
|
+
|
|
39
|
+
# 403: the key is valid but may not do this.
|
|
40
|
+
class PermissionDeniedError < APIStatusError; end
|
|
41
|
+
|
|
42
|
+
# 404: no such resource, the key cannot see it, or the feature is switched off.
|
|
43
|
+
class NotFoundError < APIStatusError; end
|
|
44
|
+
|
|
45
|
+
# 409: the resource is busy or in a state that does not allow this right now.
|
|
46
|
+
class ConflictError < APIStatusError; end
|
|
47
|
+
|
|
48
|
+
# 422: some fields were missing or invalid. #errors has one entry per problem.
|
|
49
|
+
class ValidationError < APIStatusError
|
|
50
|
+
# The details grouped by the field they are about (meta.source_field).
|
|
51
|
+
def fields
|
|
52
|
+
errors.each_with_object({}) do |error, out|
|
|
53
|
+
meta = error["meta"].is_a?(Hash) ? error["meta"] : {}
|
|
54
|
+
(out[meta["source_field"].to_s] ||= []) << error["detail"].to_s
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# 429: too many requests. #retry_after is the number of seconds to wait, if sent.
|
|
60
|
+
class RateLimitError < APIStatusError
|
|
61
|
+
attr_reader :retry_after
|
|
62
|
+
|
|
63
|
+
def initialize(message = nil, retry_after: nil, **kwargs)
|
|
64
|
+
super(message, **kwargs)
|
|
65
|
+
@retry_after = retry_after
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# 5xx: something went wrong on the panel or on the node running the server.
|
|
70
|
+
class ServerError < APIStatusError; end
|
|
71
|
+
|
|
72
|
+
# A wait such as servers.wait_for_state ran out of time. #state is the server's state when it
|
|
73
|
+
# gave up.
|
|
74
|
+
class WaitTimeoutError < Error
|
|
75
|
+
attr_reader :state
|
|
76
|
+
|
|
77
|
+
def initialize(message = nil, state: nil)
|
|
78
|
+
super(message)
|
|
79
|
+
@state = state
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A console connection could not be opened, or it ended.
|
|
84
|
+
#
|
|
85
|
+
# #reason says why: "suspended" (the server is suspended; connecting again won't help),
|
|
86
|
+
# "booting" (the node is still loading the server; try again in a few seconds), "token" (the
|
|
87
|
+
# node refused the token), "timeout", "network" (the node or the panel could not be reached, or
|
|
88
|
+
# the connection dropped), "deleted" (the server has been deleted), "throttled" (messages were
|
|
89
|
+
# sent faster than the node takes them) or "unavailable" (this way of connecting isn't
|
|
90
|
+
# available here). #transport is the way that failed, and with transport "auto", #attempts holds
|
|
91
|
+
# why each way tried failed.
|
|
92
|
+
class ConsoleError < Error
|
|
93
|
+
attr_reader :reason, :transport, :attempts
|
|
94
|
+
|
|
95
|
+
def initialize(message = nil, reason = "network", transport: nil, attempts: nil)
|
|
96
|
+
super(message)
|
|
97
|
+
@reason = reason
|
|
98
|
+
@transport = transport
|
|
99
|
+
@attempts = attempts || []
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Turning error responses into exceptions.
|
|
104
|
+
module Errors
|
|
105
|
+
BY_STATUS = {
|
|
106
|
+
400 => BadRequestError,
|
|
107
|
+
401 => AuthenticationError,
|
|
108
|
+
403 => PermissionDeniedError,
|
|
109
|
+
404 => NotFoundError,
|
|
110
|
+
409 => ConflictError,
|
|
111
|
+
422 => ValidationError,
|
|
112
|
+
}.freeze
|
|
113
|
+
|
|
114
|
+
module_function
|
|
115
|
+
|
|
116
|
+
# Seconds from a Retry-After header, or nil when it is missing or not a number.
|
|
117
|
+
def parse_retry_after(value)
|
|
118
|
+
return nil if value.nil?
|
|
119
|
+
|
|
120
|
+
seconds = Float(value.to_s.strip)
|
|
121
|
+
seconds.negative? ? 0.0 : seconds
|
|
122
|
+
rescue ArgumentError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The exception that matches an error response.
|
|
127
|
+
def for_response(response)
|
|
128
|
+
status = response.status
|
|
129
|
+
body = begin
|
|
130
|
+
JSON.parse(response.body.to_s)
|
|
131
|
+
rescue JSON::ParserError
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
errors = []
|
|
135
|
+
if body.is_a?(Hash)
|
|
136
|
+
if body["errors"].is_a?(Array)
|
|
137
|
+
errors = body["errors"].grep(Hash)
|
|
138
|
+
elsif body["error"].is_a?(String)
|
|
139
|
+
# The node (Wings) answers signed-URL requests in its own format.
|
|
140
|
+
error = { "code" => "NodeError", "status" => status.to_s, "detail" => body["error"] }
|
|
141
|
+
# The console relay passes the node's refusals on in the same format.
|
|
142
|
+
meta = body.slice("booting", "suspended", "jwt").select { |_, v| [true, false].include?(v) }
|
|
143
|
+
error["meta"] = meta unless meta.empty?
|
|
144
|
+
errors = [error]
|
|
145
|
+
elsif body["message"].is_a?(String)
|
|
146
|
+
errors = [{ "code" => "HttpException", "status" => status.to_s, "detail" => body["message"] }]
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
message = errors.empty? ? "" : (errors.first["detail"] || errors.first["code"]).to_s
|
|
150
|
+
message = "HTTP error" if message.empty?
|
|
151
|
+
message = "#{status}: #{message}"
|
|
152
|
+
|
|
153
|
+
kwargs = { status_code: status, errors: errors, response: response }
|
|
154
|
+
return RateLimitError.new(message, retry_after: parse_retry_after(response.header("retry-after")), **kwargs) if status == 429
|
|
155
|
+
|
|
156
|
+
klass = BY_STATUS[status] || (status >= 500 ? ServerError : APIStatusError)
|
|
157
|
+
klass.new(message, **kwargs)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../sillydev"
|
|
5
|
+
|
|
6
|
+
module SillyDev
|
|
7
|
+
# An MCP server that lets AI assistants look after your Silly Development servers.
|
|
8
|
+
#
|
|
9
|
+
# Run it with `sillydev mcp`. It speaks MCP (JSON-RPC 2.0, one message per line) over stdio and
|
|
10
|
+
# reads the API key from SILLYDEV_API_KEY. Give it a key limited to the servers and permissions
|
|
11
|
+
# you want the assistant to have.
|
|
12
|
+
#
|
|
13
|
+
# The tools are deliberately few: list servers, read a server's status, power actions, console
|
|
14
|
+
# commands, reading recent console output, list and read files, Git deploys and listing backups.
|
|
15
|
+
# There is nothing that spends money, deletes anything or changes who can reach a server.
|
|
16
|
+
class MCPServer
|
|
17
|
+
PROTOCOL_VERSIONS = %w[2025-06-18 2025-03-26 2024-11-05].freeze
|
|
18
|
+
|
|
19
|
+
# read_file returns at most this many characters, so one large log cannot swamp the context.
|
|
20
|
+
MAX_FILE_CHARS = 100_000
|
|
21
|
+
|
|
22
|
+
INSTRUCTIONS = "Tools for the user's Silly Development servers (Discord bots and other apps). " \
|
|
23
|
+
"Servers are named by their 8-character identifier, which list_servers returns. " \
|
|
24
|
+
"Power actions and console commands affect a live server: say what you are about to do first."
|
|
25
|
+
|
|
26
|
+
SERVER = { "server" => { "type" => "string", "description" => "The server's 8-character identifier or UUID." } }.freeze
|
|
27
|
+
|
|
28
|
+
def self.schema(properties = {}, required = [])
|
|
29
|
+
{ "type" => "object", "properties" => properties, "required" => required }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
TOOLS = [
|
|
33
|
+
["list_servers", "List the servers this API key can reach, with their identifiers, names and limits.", schema],
|
|
34
|
+
["server_status",
|
|
35
|
+
"Get a server's live state (offline, starting, running, stopping) and its CPU, memory, disk and network use.",
|
|
36
|
+
schema(SERVER, ["server"])],
|
|
37
|
+
["power_action", "Start, stop, restart or kill a server. signal is one of start, stop, restart, kill.",
|
|
38
|
+
schema(SERVER.merge("signal" => { "type" => "string", "enum" => %w[start stop restart kill] }), %w[server signal])],
|
|
39
|
+
["send_command", "Send a command to a server's console, as if typed into it.",
|
|
40
|
+
schema(SERVER.merge("command" => { "type" => "string" }), %w[server command])],
|
|
41
|
+
["read_console",
|
|
42
|
+
"Read a server's recent console output (about the last 150 lines), oldest first. lines limits it to the " \
|
|
43
|
+
"latest lines. Empty while the server is offline.",
|
|
44
|
+
schema(SERVER.merge("lines" => { "type" => "integer", "default" => 100 }), ["server"])],
|
|
45
|
+
["list_files", "List the files and folders in one directory of a server. directory defaults to /.",
|
|
46
|
+
schema(SERVER.merge("directory" => { "type" => "string", "default" => "/" }), ["server"])],
|
|
47
|
+
["read_file", "Read a text file from a server, such as a config file or a log.",
|
|
48
|
+
schema(SERVER.merge("path" => { "type" => "string" }), %w[server path])],
|
|
49
|
+
["git_deploy", "Deploy the Git repository linked to a server now. Returns the queued deployment.",
|
|
50
|
+
schema(SERVER, ["server"])],
|
|
51
|
+
["list_backups", "List a server's backups: name, size, whether it finished and succeeded, and when.",
|
|
52
|
+
schema(SERVER, ["server"])],
|
|
53
|
+
].map { |name, description, input| { "name" => name, "description" => description, "inputSchema" => input } }.freeze
|
|
54
|
+
|
|
55
|
+
TOOL_NAMES = TOOLS.map { |tool| tool["name"] }.freeze
|
|
56
|
+
|
|
57
|
+
def self.main(api_key: nil, base_url: nil, stdin: $stdin, stdout: $stdout, stderr: $stderr)
|
|
58
|
+
client = Client.new(api_key, base_url: base_url)
|
|
59
|
+
new(client).serve(stdin, stdout)
|
|
60
|
+
0
|
|
61
|
+
rescue Error => e
|
|
62
|
+
stderr.puts("sillydev mcp: #{e.message}")
|
|
63
|
+
1
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def initialize(client)
|
|
67
|
+
@client = client
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Answers messages from input, one JSON-RPC message per line, until it ends.
|
|
71
|
+
def serve(input, output)
|
|
72
|
+
output.sync = true if output.respond_to?(:sync=)
|
|
73
|
+
input.each_line do |line|
|
|
74
|
+
next if line.strip.empty?
|
|
75
|
+
|
|
76
|
+
reply = handle_line(line)
|
|
77
|
+
output.puts(JSON.generate(reply)) if reply
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The answer to one line (a message or a batch), or nil for notifications.
|
|
82
|
+
def handle_line(line)
|
|
83
|
+
message = JSON.parse(line)
|
|
84
|
+
if message.is_a?(Array)
|
|
85
|
+
replies = message.filter_map { |m| handle(m) }
|
|
86
|
+
return replies.empty? ? nil : replies
|
|
87
|
+
end
|
|
88
|
+
handle(message)
|
|
89
|
+
rescue JSON::ParserError
|
|
90
|
+
error_reply(nil, -32_700, "Parse error")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def handle(message)
|
|
94
|
+
return error_reply(nil, -32_600, "Invalid Request") unless message.is_a?(Hash)
|
|
95
|
+
|
|
96
|
+
id = message["id"]
|
|
97
|
+
method = message["method"]
|
|
98
|
+
return nil if method.nil? || (!message.key?("id") && method.start_with?("notifications/"))
|
|
99
|
+
return nil unless message.key?("id")
|
|
100
|
+
|
|
101
|
+
result = case method
|
|
102
|
+
when "initialize" then initialize_result(message["params"] || {})
|
|
103
|
+
when "ping" then {}
|
|
104
|
+
when "tools/list" then { "tools" => TOOLS }
|
|
105
|
+
when "tools/call" then call_tool(message["params"] || {})
|
|
106
|
+
else return error_reply(id, -32_601, "Method not found: #{method}")
|
|
107
|
+
end
|
|
108
|
+
{ "jsonrpc" => "2.0", "id" => id, "result" => result }
|
|
109
|
+
rescue ArgumentError => e
|
|
110
|
+
error_reply(id, -32_602, e.message)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Runs one tool. Errors from the API come back as a tool result with isError, so the
|
|
114
|
+
# assistant can read them.
|
|
115
|
+
def call_tool(params)
|
|
116
|
+
name = params["name"]
|
|
117
|
+
args = params["arguments"].is_a?(Hash) ? params["arguments"] : {}
|
|
118
|
+
raise ArgumentError, "Unknown tool: #{name}" unless TOOL_NAMES.include?(name)
|
|
119
|
+
|
|
120
|
+
value = run_tool(name, args)
|
|
121
|
+
text = value.is_a?(String) ? value : JSON.pretty_generate(value)
|
|
122
|
+
{ "content" => [{ "type" => "text", "text" => text }], "isError" => false }
|
|
123
|
+
rescue Error, ToolError => e
|
|
124
|
+
{ "content" => [{ "type" => "text", "text" => e.message }], "isError" => true }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
class ToolError < StandardError; end
|
|
130
|
+
|
|
131
|
+
def initialize_result(params)
|
|
132
|
+
asked = params["protocolVersion"]
|
|
133
|
+
{
|
|
134
|
+
"protocolVersion" => PROTOCOL_VERSIONS.include?(asked) ? asked : PROTOCOL_VERSIONS.first,
|
|
135
|
+
"capabilities" => { "tools" => { "listChanged" => false } },
|
|
136
|
+
"serverInfo" => { "name" => "sillydev", "version" => VERSION },
|
|
137
|
+
"instructions" => INSTRUCTIONS,
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def arg(args, name)
|
|
142
|
+
value = args[name]
|
|
143
|
+
raise ToolError, "#{name} is required" if value.nil? || value.to_s.empty?
|
|
144
|
+
|
|
145
|
+
value.to_s
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def run_tool(name, args)
|
|
149
|
+
panel = @client.panel
|
|
150
|
+
case name
|
|
151
|
+
when "list_servers"
|
|
152
|
+
keys = %w[identifier uuid name description node limits is_suspended is_installing is_paid]
|
|
153
|
+
panel.servers.list.map { |s| s.slice(*keys) }
|
|
154
|
+
when "server_status" then panel.servers.resources(arg(args, "server"))
|
|
155
|
+
when "power_action"
|
|
156
|
+
server = arg(args, "server")
|
|
157
|
+
signal = arg(args, "signal")
|
|
158
|
+
raise ToolError, "signal must be start, stop, restart or kill" unless %w[start stop restart kill].include?(signal)
|
|
159
|
+
|
|
160
|
+
panel.servers.power(server, signal)
|
|
161
|
+
"Sent #{signal} to #{server}."
|
|
162
|
+
when "send_command"
|
|
163
|
+
server = arg(args, "server")
|
|
164
|
+
command = arg(args, "command")
|
|
165
|
+
panel.servers.command(server, command)
|
|
166
|
+
"Sent to #{server}: #{command}"
|
|
167
|
+
when "read_console" then read_console(arg(args, "server"), args["lines"])
|
|
168
|
+
when "list_files"
|
|
169
|
+
files = panel.files.list(arg(args, "server"), (args["directory"] || "/").to_s)
|
|
170
|
+
files.map { |f| f.slice("name", "is_file", "size", "mimetype", "modified_at") }
|
|
171
|
+
when "read_file"
|
|
172
|
+
text = panel.files.read(arg(args, "server"), arg(args, "path"))
|
|
173
|
+
return text if text.length <= MAX_FILE_CHARS
|
|
174
|
+
|
|
175
|
+
"#{text[-MAX_FILE_CHARS..]}\n[showing the last #{MAX_FILE_CHARS} characters of #{text.length}]"
|
|
176
|
+
when "git_deploy" then panel.git.deploy(arg(args, "server"))
|
|
177
|
+
when "list_backups" then panel.backups.list(arg(args, "server")).to_a
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def read_console(server, lines)
|
|
182
|
+
history = @client.panel.console.history(server)
|
|
183
|
+
return "#{server} has no console output: it is offline, or has printed nothing since it started." if history.empty?
|
|
184
|
+
|
|
185
|
+
count = [Integer(lines || 100, exception: false) || 100, 1].max
|
|
186
|
+
history.last(count).join("\n")[-MAX_FILE_CHARS..] || history.last(count).join("\n")
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def error_reply(id, code, message)
|
|
190
|
+
{ "jsonrpc" => "2.0", "id" => id, "error" => { "code" => code, "message" => message } }
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SillyDev
|
|
4
|
+
module Monitoring
|
|
5
|
+
# Seconds per aggregation window. Each series sends at most one point per window.
|
|
6
|
+
WINDOW = 10
|
|
7
|
+
|
|
8
|
+
# Upper bounds (ms) of the first 15 histogram buckets; the 16th is everything above 60000.
|
|
9
|
+
BOUNDS = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10_000, 30_000, 60_000].freeze
|
|
10
|
+
BUCKETS = BOUNDS.length + 1
|
|
11
|
+
|
|
12
|
+
# Client-side pre-aggregation of metrics into fixed windows (see feathers/docs/ingest.md). Holds
|
|
13
|
+
# one value per (window, name, type, tags). Not thread-safe: the client locks around it.
|
|
14
|
+
class Aggregator
|
|
15
|
+
Histogram = Struct.new(:count, :sum, :min, :max, :buckets) do # rubocop:disable Lint/StructNewOverride
|
|
16
|
+
def add(value)
|
|
17
|
+
self.count += 1
|
|
18
|
+
self.sum += value
|
|
19
|
+
self.min = value if value < min
|
|
20
|
+
self.max = value if value > max
|
|
21
|
+
buckets[Aggregator.bucket_index(value)] += 1
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The first bucket whose upper bound is >= value (the 16th for anything above 60000).
|
|
26
|
+
def self.bucket_index(value)
|
|
27
|
+
BOUNDS.bsearch_index { |bound| bound >= value } || BOUNDS.length
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attr_accessor :dropped
|
|
31
|
+
attr_reader :max_series
|
|
32
|
+
|
|
33
|
+
def initialize(max_series = 2000)
|
|
34
|
+
@max_series = max_series
|
|
35
|
+
@series = {}
|
|
36
|
+
@dropped = 0
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def size
|
|
40
|
+
@series.size
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def counter(ts, name, value, tags)
|
|
44
|
+
key = slot(ts, name, "counter", tags)
|
|
45
|
+
@series[key] = (@series[key] || 0) + value if key
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def gauge(ts, name, value, tags)
|
|
49
|
+
key = slot(ts, name, "gauge", tags)
|
|
50
|
+
@series[key] = value if key
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def histogram(ts, name, value, tags)
|
|
54
|
+
key = slot(ts, name, "histogram", tags)
|
|
55
|
+
return unless key
|
|
56
|
+
|
|
57
|
+
hist = @series[key]
|
|
58
|
+
hist = @series[key] = Histogram.new(0, 0.0, Float::INFINITY, -Float::INFINITY, Array.new(BUCKETS, 0)) unless hist.is_a?(Histogram)
|
|
59
|
+
hist.add(value)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Removes and returns the points of every window that has ended by now (all when nil).
|
|
63
|
+
def drain(now = nil)
|
|
64
|
+
cutoff = now.nil? ? nil : (now.to_f / WINDOW).floor * WINDOW
|
|
65
|
+
out = []
|
|
66
|
+
@series.keys.sort_by(&:first).each do |key|
|
|
67
|
+
start, name, kind, tags = key
|
|
68
|
+
next if cutoff && start >= cutoff
|
|
69
|
+
|
|
70
|
+
value = @series.delete(key)
|
|
71
|
+
item = { "name" => name, "type" => kind, "ts" => start }
|
|
72
|
+
if kind == "histogram"
|
|
73
|
+
item.merge!("count" => value.count, "sum" => num(value.sum), "min" => num(value.min),
|
|
74
|
+
"max" => num(value.max), "buckets" => value.buckets.dup)
|
|
75
|
+
else
|
|
76
|
+
item["value"] = num(value)
|
|
77
|
+
end
|
|
78
|
+
item["tags"] = tags.to_h unless tags.empty?
|
|
79
|
+
out << item
|
|
80
|
+
end
|
|
81
|
+
out
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def clear
|
|
85
|
+
@series.clear
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def slot(ts, name, kind, tags)
|
|
91
|
+
key = [(ts.to_f / WINDOW).floor * WINDOW, name, kind, tags]
|
|
92
|
+
if !@series.key?(key) && @series.size >= @max_series
|
|
93
|
+
@dropped += 1
|
|
94
|
+
return nil
|
|
95
|
+
end
|
|
96
|
+
key
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def num(value)
|
|
100
|
+
return value.to_i if value.is_a?(Float) && value.finite? && value == value.floor
|
|
101
|
+
return value.round(6) if value.is_a?(Float)
|
|
102
|
+
|
|
103
|
+
value
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|