asterism 0.8.0 → 0.10.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.
@@ -0,0 +1,77 @@
1
+ # `asterism status hz`: how often an application's status changes, as
2
+ # `ros2 topic hz` tells how often a topic is published. Each change is a
3
+ # time (a monotonic clock, given or Process.clock_gettime); the intervals
4
+ # between the last ones (a window of them) give the average rate and the
5
+ # intervals' min, max and standard deviation. A put that brings the same
6
+ # status again (as after a reconnection) is not a change.
7
+ module Asterism
8
+ class CLI
9
+ class Hz
10
+ attr_reader :count
11
+
12
+ # window: how many of the last intervals count. clock: a callable
13
+ # that returns the time in seconds (monotonic).
14
+ def initialize(window: 1000, clock: nil)
15
+ @window = window
16
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
17
+ @times = []
18
+ @last = nil
19
+ @seeded = false
20
+ @count = 0
21
+ @reported = 0
22
+ end
23
+
24
+ # The status there at the start: what the first put is compared with
25
+ # (not a change itself).
26
+ def seed(status)
27
+ @last = status
28
+ @seeded = true
29
+ end
30
+
31
+ # A put of the status: a change (true) when it differs from the last
32
+ # one seen (the first put without a seed is one).
33
+ def put(status)
34
+ return false if @seeded && status == @last
35
+ @last = status
36
+ @seeded = true
37
+ add(@clock.call)
38
+ true
39
+ end
40
+
41
+ # A change at time t (seconds).
42
+ def add(t)
43
+ @count += 1
44
+ @times << t
45
+ @times.shift while @times.size > @window + 1
46
+ end
47
+
48
+ # {"rate_hz", "min_s", "max_s", "std_s", "window", "count"} over the
49
+ # intervals in the window, or nil before two changes.
50
+ def stats
51
+ return nil if @times.size < 2
52
+ ints = @times.each_cons(2).map { |a, b| b - a }
53
+ avg = ints.sum / ints.size
54
+ var = ints.sum { (_1 - avg)**2 } / ints.size
55
+ { "rate_hz" => avg.positive? ? 1.0 / avg : nil, "min_s" => ints.min, "max_s" => ints.max,
56
+ "std_s" => Math.sqrt(var), "window" => ints.size, "count" => @count }
57
+ end
58
+
59
+ # stats when a change came since the last report (else nil): what a
60
+ # period prints.
61
+ def report
62
+ return nil if @count == @reported
63
+ s = stats
64
+ @reported = @count if s
65
+ s
66
+ end
67
+
68
+ # The lines of a report, as ros2 topic hz prints them.
69
+ def self.text(s)
70
+ rate = s["rate_hz"] ? format("%.3f", s["rate_hz"]) : "inf"
71
+ ["average rate: #{rate} Hz",
72
+ format(" min: %.3f s max: %.3f s std dev: %.3f s window: %d", s["min_s"], s["max_s"], s["std_s"],
73
+ s["window"])]
74
+ end
75
+ end
76
+ end
77
+ end
@@ -9,6 +9,10 @@
9
9
  # and objects (always; reads)
10
10
  # asterism_objects the objects and their methods (meta) (always; reads)
11
11
  # asterism_status the applications' statuses (always; reads)
12
+ # asterism_values the latest value of each key the applications put
13
+ # on (asterism/<node>/<app>/<object>/<more>), as this
14
+ # server received them since it started (always;
15
+ # reads; lib/asterism/cli/values.rb)
12
16
  # <node>.<app>.<object>.<method>
13
17
  # one per method that an --allow pattern names, made
14
18
  # from the object's meta: the arguments' names the
@@ -30,7 +34,8 @@ module Asterism
30
34
  These tools look at an Asterism network: Ruby programs and boards that expose objects to each other over
31
35
  Zenoh. Paths are <node>/<app>/<object>. asterism_nodes lists the nodes and their applications (with the
32
36
  status each application tells about itself, a JSON object of its own), asterism_objects the objects and
33
- their methods, asterism_status the statuses. The other tools call one method each, on the object their
37
+ their methods, asterism_status the statuses, asterism_values the latest values the applications put on
38
+ their own keys (a game table's state). The other tools call one method each, on the object their
34
39
  name says (<node>.<app>.<object>.<method>); only the methods this server was started to allow are there.
35
40
  A call's answer is {"value": ...}; an error says its kind (raised, not_exposed, arguments, ...).
36
41
  TEXT
@@ -43,13 +48,17 @@ module Asterism
43
48
  @call_tools = {} # tool name => [path, method, arity, meta entry]
44
49
  @signature = nil
45
50
  @lock = Mutex.new
51
+ @values = Values::Store.new(alive: ->(app) { !::Asterism.apps(app).empty? })
46
52
  end
47
53
 
54
+ attr_reader :values
55
+
48
56
  def run
49
57
  @server = ::MCP::Server.new(name: "asterism", version: ::Asterism::VERSION, instructions: INSTRUCTIONS,
50
58
  tools: read_tools)
51
59
  @transport = ::MCP::Server::Transports::StdioTransport.new(@server)
52
60
  @server.transport = @transport
61
+ remember_values
53
62
  refresh
54
63
  @log.puts "asterism mcp: #{@net.node_id}/#{@net.app}; #{@call_tools.size} call tools" \
55
64
  "#{@allow.empty? ? ' (no --allow: read-only)' : ''}"
@@ -59,6 +68,16 @@ module Asterism
59
68
  @refresher&.kill
60
69
  end
61
70
 
71
+ # From now on, the puts on the applications' own keys (asterism/**
72
+ # does not match @profile and @status), and the applications that
73
+ # leave (their values are marked).
74
+ def remember_values
75
+ @net.session.subscribe("#{Values::PREFIX}**", depth: 1024) { |s| @values.put(s.key, s.payload) }
76
+ @net.session.liveliness_watch("#{Values::PREFIX}*/*", depth: 1024) do |key, alive|
77
+ @values.left(key.delete_prefix(Values::PREFIX)) unless alive
78
+ end
79
+ end
80
+
62
81
  # ------------------------------------------------------------ the list
63
82
 
64
83
  def refresh_loop
@@ -153,10 +172,46 @@ module Asterism
153
172
  (node.nil? || node == n) && (app.nil? || app == a)
154
173
  end
155
174
  end
175
+ end,
176
+ ::MCP::Tool.define(
177
+ name: "asterism_values",
178
+ description: VALUES_DOC,
179
+ input_schema: {
180
+ properties: {
181
+ pattern: { type: "string",
182
+ description: "keys as node/app/object/more, with * (one part) and ** (any parts); " \
183
+ "default ** (every key). A key always has a part after the object: " \
184
+ "games/games/othello1/** or games/*/*/state, not games/games/othello1" },
185
+ wait: { type: "number",
186
+ description: "seconds (at most #{Values::Store::MAX_WAIT}) to wait for the first put " \
187
+ "that matches when none has come yet; default 0 (answer now)" }
188
+ }
189
+ },
190
+ annotations: { read_only_hint: true }
191
+ ) do |server_context:, pattern: "**", wait: 0|
192
+ bridge.answer { bridge.values.answer(pattern, wait: wait) }
193
+ rescue ArgumentError => e
194
+ bridge.error(e.message)
156
195
  end
157
196
  ]
158
197
  end
159
198
 
199
+ VALUES_DOC = "The latest value each application put on its own keys (asterism/<node>/<app>/<object>/<more>: " \
200
+ "a game table's state, a log), as this server received them: {\"since\", \"values\": " \
201
+ "[{\"key\", \"value\", \"t\", \"age_s\", \"app\", \"bytes\", \"format\"}]}. " \
202
+ "A put comes only when the value changes, and this server remembers only what it received " \
203
+ "since it started (since): a key that has not been put since then is not listed. Give wait " \
204
+ "(seconds) to wait for the first put that matches when none has come yet (a quiet table, " \
205
+ "just after the start). age_s: seconds since it came. app: alive; gone (the application is " \
206
+ "not on the network now: this is its last value, not the present); restarted (it left and " \
207
+ "came back after this value). value: MessagePack or JSON as it is, text as a string; a " \
208
+ "value whose JSON is longer than #{Values::Store::VALUE_CUT} bytes is cut (truncated: " \
209
+ "true, value: the start of its JSON text). It keeps at most #{Values::Store::MAX_KEYS} keys " \
210
+ "(those received longest ago are forgotten first) and lists at most " \
211
+ "#{Values::Store::MAX_ANSWER} (more: how many it left out). Nothing came: the note says so " \
212
+ "(not put since the start, or the router does not let this server read it). The statuses " \
213
+ "(@status) are asterism_status's."
214
+
160
215
  def objects(pattern)
161
216
  paths = @survey.objects(pattern)
162
217
  metas = @survey.metas(paths)
@@ -0,0 +1,493 @@
1
+ # The noun-verb commands of the command line (node, app, object, status:
2
+ # lib/asterism/cli.rb's NOUNS), `completion` and the hidden `__complete`
3
+ # (lib/asterism/cli/complete.rb).
4
+ module Asterism
5
+ class CLI
6
+ # ------------------------------------------------------------ node
7
+
8
+ def cmd_node_list(_rest)
9
+ connect do |_net, sv|
10
+ names = sv.nodes
11
+ structured? ? emit(names) : names.each { @out.puts paint(_1, :node) }
12
+ end
13
+ 0
14
+ end
15
+
16
+ def cmd_node_info(rest)
17
+ node = rest.shift
18
+ raise ArgumentError, "node info: which node? (asterism node info NODE)" unless node && !node.empty?
19
+ raise ArgumentError, "node info: NODE is a node's name (no /)" if node.include?("/")
20
+ code = 0
21
+ connect do |_net, sv|
22
+ entry = sv.tree(node: node).first
23
+ if entry.nil?
24
+ @err.puts "asterism: no node #{node} is visible"
25
+ code = 1
26
+ elsif structured?
27
+ emit(entry)
28
+ else
29
+ @out.puts paint(entry["node"], :node)
30
+ if entry["profile"]
31
+ pr = entry["profile"].except("icon")
32
+ Text.wrap(pr.map { |k, v| Text.pair(k, v) }, style.width, first: " #{paint('profile:', :note)} ",
33
+ rest: " ", break_long: false)
34
+ .each { @out.puts _1 }
35
+ end
36
+ entry["apps"].each { |a| app_block(a["app"], a, 2) }
37
+ end
38
+ end
39
+ code
40
+ end
41
+
42
+ # ------------------------------------------------------------ app
43
+
44
+ def cmd_app_list(_rest)
45
+ connect do |_net, sv|
46
+ names = sv.apps
47
+ structured? ? emit(names) : names.each { @out.puts paint_path(_1) }
48
+ end
49
+ 0
50
+ end
51
+
52
+ def cmd_app_info(rest)
53
+ node, app = app_path!(rest, "app info").split("/")
54
+ code = 0
55
+ connect do |_net, sv|
56
+ a = sv.tree(node: node, app: app).first&.dig("apps")&.find { _1["app"] == app }
57
+ if a.nil?
58
+ @err.puts "asterism: no application #{node}/#{app} is visible"
59
+ code = 1
60
+ elsif structured?
61
+ emit({ "node" => node }.merge(a))
62
+ else
63
+ app_block("#{node}/#{app}", a, 0)
64
+ end
65
+ end
66
+ code
67
+ end
68
+
69
+ # ------------------------------------------------------------ object
70
+
71
+ def cmd_object_list(rest)
72
+ pattern = rest.shift || "**"
73
+ connect do |_net, sv|
74
+ paths = sv.objects(pattern)
75
+ structured? ? emit(paths) : paths.each { @out.puts paint_path(_1) }
76
+ end
77
+ 0
78
+ end
79
+
80
+ def cmd_object_info(rest)
81
+ path = rest.shift
82
+ unless path && path.split("/", -1).size == 3 && path.split("/").none? { _1.empty? || _1.include?("*") }
83
+ raise ArgumentError, "object info: PATH is node/app/object (a pattern: asterism object list PATTERN)"
84
+ end
85
+ code = 0
86
+ connect do |_net, sv|
87
+ if sv.objects(path).empty?
88
+ @err.puts "asterism: no object #{path} is visible"
89
+ code = 1
90
+ next
91
+ end
92
+ methods = sv.metas([path])[path]
93
+ if structured?
94
+ h = { "path" => path, "methods" => methods }
95
+ h["doc"] = sv.object_doc(path) if sv.object_doc(path)
96
+ emit(h)
97
+ else
98
+ object_entry(path, methods, sv.object_doc(path))
99
+ end
100
+ end
101
+ code
102
+ end
103
+
104
+ def cmd_object_find(rest)
105
+ want = rest.shift
106
+ raise ArgumentError, "object find: which method? (asterism object find METHOD; * as a wildcard)" unless want
107
+ found = []
108
+ connect do |_net, sv|
109
+ paths = sv.objects
110
+ metas = sv.metas(paths)
111
+ paths.each do |p|
112
+ ms = (metas[p] || []).select { File.fnmatch?(want, _1["name"]) }
113
+ found << { "path" => p, "methods" => ms } unless ms.empty?
114
+ end
115
+ structured? ? emit(found) : found.each { @out.puts paint_path(_1["path"]) }
116
+ end
117
+ 0
118
+ end
119
+
120
+ # The next value the application puts on its own keys (PATH/KEY: a put
121
+ # is kept nowhere, so it waits for one; --timeout, 5.0 s).
122
+ def cmd_object_get(rest)
123
+ pattern = put_pattern!(rest, "object get")
124
+ wait = @opts[:put_timeout] || 5.0
125
+ raise ArgumentError, "--timeout: more than 0" unless wait.positive?
126
+ code = 0
127
+ connect do |net, _sv|
128
+ q = Queue.new
129
+ net.session.subscribe(Values.key_expr(pattern), depth: 16) { |s| q << [s.key, s.payload, Time.now] }
130
+ got = q.pop(timeout: wait)
131
+ if got.nil?
132
+ @err.puts nothing_came(pattern, wait)
133
+ code = 1
134
+ next
135
+ end
136
+ e = Values.entry(*got)
137
+ if structured?
138
+ emit(e)
139
+ else
140
+ put_lines(got[2], e, nil).each { @out.puts _1 }
141
+ end
142
+ end
143
+ code
144
+ end
145
+
146
+ # The values the application puts on its own keys, as they come: the
147
+ # text shows a key's value whole the first time (and always with
148
+ # --raw), then what changed; -o json / yaml each put whole.
149
+ def cmd_object_echo(rest)
150
+ pattern = put_pattern!(rest, "object echo")
151
+ code = 0
152
+ connect do |net, _sv|
153
+ q = Queue.new
154
+ net.session.subscribe(Values.key_expr(pattern), depth: 256) { |s| q << [s.key, s.payload, Time.now] }
155
+ seen = 0
156
+ last = {}
157
+ until_stopped(net) do |left|
158
+ got = q.pop(timeout: [left || 0.2, 0.2].min)
159
+ next unless got
160
+ seen += 1
161
+ e = Values.entry(*got)
162
+ if structured?
163
+ emit_line(e)
164
+ else
165
+ whole = @opts[:raw] || !last.key?(e["key"])
166
+ old = last[e["key"]]
167
+ last[e["key"]] = e["value"]
168
+ lines = whole ? put_lines(got[2], e, nil) : put_lines(got[2], e, old, again: true)
169
+ lines&.each { @out.puts _1 }
170
+ end
171
+ @out.flush
172
+ break if @opts[:once]
173
+ end
174
+ if seen.zero?
175
+ @err.puts nothing_came(pattern, @opts[:duration])
176
+ code = 1 if @opts[:once]
177
+ end
178
+ end
179
+ code
180
+ end
181
+
182
+ # ------------------------------------------------------------ status
183
+
184
+ def cmd_status_get(rest)
185
+ path = app_path!(rest, "status get")
186
+ code = 0
187
+ connect do |_net, sv|
188
+ st = sv.statuses([path])[path]
189
+ if st.nil?
190
+ @err.puts no_status(path)
191
+ code = 1
192
+ elsif structured?
193
+ emit(json_safe(st))
194
+ else
195
+ Text.status_lines(json_safe(st), style.width).each { @out.puts _1 }
196
+ end
197
+ end
198
+ code
199
+ end
200
+
201
+ # The status, then each change as it comes (the text: what changed, as
202
+ # watch tells it); --once: the status now, once.
203
+ def cmd_status_echo(rest)
204
+ path = app_path!(rest, "status echo")
205
+ code = 0
206
+ connect do |net, sv|
207
+ q = Queue.new
208
+ subscribe_status(net, path) { |st| q << [Time.now, st] } unless @opts[:once]
209
+ st = sv.statuses([path])[path]
210
+ if st
211
+ echo_event(Time.now, path, st, true)
212
+ elsif @opts[:once]
213
+ @err.puts no_status(path)
214
+ code = 1
215
+ end
216
+ next if @opts[:once]
217
+
218
+ until_stopped(net) do |left|
219
+ got = q.pop(timeout: [left || 0.2, 0.2].min)
220
+ echo_event(got[0], path, got[1], false) if got
221
+ end
222
+ end
223
+ code
224
+ end
225
+
226
+ # How often the status changes: every --period, when a change came, the
227
+ # average rate and the intervals (Hz).
228
+ def cmd_status_hz(rest)
229
+ path = app_path!(rest, "status hz")
230
+ raise ArgumentError, "--window: 1 or more" unless @opts[:window].positive?
231
+ raise ArgumentError, "--period: more than 0" unless @opts[:period].positive?
232
+ connect do |net, sv|
233
+ hz = Hz.new(window: @opts[:window])
234
+ lock = Mutex.new
235
+ subscribe_status(net, path) { |st| lock.synchronize { hz.put(st) } }
236
+ st = sv.statuses([path])[path]
237
+ lock.synchronize { hz.seed(st) } if st
238
+ @err.puts "asterism: #{path}: counting the status's changes (Ctrl-C to stop)" unless structured?
239
+ nxt = now + @opts[:period]
240
+ until_stopped(net) do |_left|
241
+ sleep 0.05
242
+ next if now < nxt
243
+ nxt += @opts[:period]
244
+ s = lock.synchronize { hz.report }
245
+ next unless s
246
+ if structured?
247
+ emit_line({ "t" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"), "path" => path }.merge(s))
248
+ else
249
+ Hz.text(s).each { @out.puts _1 }
250
+ end
251
+ @out.flush
252
+ end
253
+ end
254
+ 0
255
+ end
256
+
257
+ # ------------------------------------------------------------ completion
258
+
259
+ def cmd_completion(rest)
260
+ case rest.shift
261
+ when "bash" then @out.write(Complete::BASH)
262
+ when "zsh" then @out.write(Complete::ZSH)
263
+ else raise ArgumentError, "completion: bash or zsh (source <(asterism completion bash))"
264
+ end
265
+ 0
266
+ end
267
+
268
+ # The candidates for the word being completed, one a line. The words
269
+ # come as arguments (the last one the word being completed), after
270
+ # "--"; or (--bash LINE) as bash's line up to the cursor.
271
+ def cmd_complete(argv)
272
+ bash = argv.first == "--bash"
273
+ words = if bash
274
+ Complete.split_line(argv[1].to_s)
275
+ else
276
+ argv.first == "--" ? argv.drop(1) : argv
277
+ end
278
+ words = [""] if words.empty?
279
+ @complete_words = words
280
+ cands = Complete.new(self, CompleteSource.new(self, words)).candidates(words)
281
+ cands = Complete.bash_trim(cands, words.last) if bash && cands != [Complete::FILES]
282
+ cands.each { @out.puts _1 }
283
+ @out.flush
284
+ # A connection still trying past the budget is left behind: the
285
+ # process ends without waiting for it.
286
+ Process.exit!(0) if @complete_abandoned
287
+ 0
288
+ end
289
+
290
+ # What __complete asks the network for, through the cache: the names
291
+ # (the tokens) and an object's methods (its meta).
292
+ class CompleteSource
293
+ def initialize(cli, words)
294
+ @cli = cli
295
+ @words = words
296
+ end
297
+
298
+ def names
299
+ cached("names") do
300
+ @cli.complete_fetch { |sv| { "apps" => sv.apps, "objects" => sv.objects } }
301
+ end
302
+ end
303
+
304
+ def methods(path)
305
+ cached("methods #{path}") do
306
+ @cli.complete_fetch { |sv| sv.metas([path])[path]&.map { _1["name"] } }
307
+ end
308
+ end
309
+
310
+ def all_methods
311
+ cached("methods") do
312
+ @cli.complete_fetch { |sv| sv.metas(sv.objects).values.flatten.map { _1["name"] }.uniq.sort }
313
+ end
314
+ end
315
+
316
+ private
317
+
318
+ def cached(entry, &blk)
319
+ net = @cli.complete_network(@words)
320
+ return nil if net.none?
321
+ dir = Complete::Cache.dir(@cli.env)
322
+ return blk.call unless dir
323
+ Complete::Cache.new(dir: dir, network: net).fetch(entry, &blk)
324
+ end
325
+ end
326
+
327
+ attr_reader :env
328
+
329
+ # Where the network is for __complete: the options among the words,
330
+ # else the environment (network). [router, credentials (its full path),
331
+ # peer]
332
+ def complete_network(words)
333
+ words.each_cons(2) do |a, b|
334
+ case a
335
+ when "--router" then @opts[:router] = b
336
+ when "--credentials" then @opts[:credentials] = b
337
+ when "--peer" then @opts[:peer] = b
338
+ end
339
+ end
340
+ r, c, pr = network
341
+ [r, c && File.expand_path(c), pr]
342
+ end
343
+
344
+ # Connects for at most Complete::BUDGET seconds and yields the Survey;
345
+ # the block's value, or nil when the network did not answer in time.
346
+ def complete_fetch
347
+ @opts[:settle] = 0.6
348
+ @opts[:timeout] = 0.6
349
+ out = Queue.new
350
+ th = Thread.new do
351
+ err = @err
352
+ @err = File.open(File::NULL, "w")
353
+ begin
354
+ connect { |_net, sv| out << yield(sv) }
355
+ rescue StandardError, ScriptError
356
+ out << nil
357
+ ensure
358
+ @err = err
359
+ end
360
+ end
361
+ v = out.pop(timeout: Complete::BUDGET)
362
+ if v.nil? && th.alive?
363
+ @complete_abandoned = true
364
+ return nil
365
+ end
366
+ th.join(0.5) || (@complete_abandoned = true)
367
+ v
368
+ end
369
+
370
+ # ------------------------------------------------------------ helpers
371
+
372
+ def app_path!(rest, what)
373
+ path = rest.shift
374
+ ok = path && path.split("/", -1).size == 2 && path.split("/").none? { _1.empty? || _1.include?("*") }
375
+ raise ArgumentError, "#{what}: which application? (asterism #{what} NODE/APP)" unless ok
376
+ path
377
+ end
378
+
379
+ # PATH (node/app/object, * for a part) and KEY (the rest, ** by
380
+ # default) as one pattern.
381
+ def put_pattern!(rest, what)
382
+ path = rest.shift
383
+ key = rest.shift || "**"
384
+ parts = path.to_s.split("/", -1)
385
+ unless parts.size == 3 && parts.none? { _1.empty? || (_1.include?("*") && _1 != "*") }
386
+ raise ArgumentError, "#{what}: PATH is node/app/object (* for a part), then KEY (asterism #{what} " \
387
+ "games/games/othello1 state)"
388
+ end
389
+ Values.check_pattern!("#{path}/#{key}")
390
+ end
391
+
392
+ def nothing_came(pattern, seconds)
393
+ "asterism: nothing came on #{pattern}#{seconds ? " in #{format('%g', seconds)} s" : ''} " \
394
+ "(the router does not let this node read it, or it has not changed)"
395
+ end
396
+
397
+ # The lines of one put received at t: the time, the key and the value
398
+ # whole; again (the key's value before was old): a line for each thing
399
+ # that changed in a Hash, the value whole otherwise (nil: no change).
400
+ def put_lines(t, e, old, again: false)
401
+ head = "#{paint(t.strftime('%H:%M:%S.%L'), :time)} "
402
+ v = e["value"]
403
+ if again && old.is_a?(Hash) && v.is_a?(Hash)
404
+ changes = Text.status_diff(old, v)
405
+ return nil if changes.empty?
406
+ return changes.map { |c| "#{head}#{paint(e['key'], :object)} #{change_text(c)}" }
407
+ end
408
+ return nil if again && old == v
409
+ first = "#{head}#{paint(e['key'], :object)}"
410
+ return ["#{first} #{Text.inline(v)}"] unless v.is_a?(Hash)
411
+ [first, *Text.status_lines(v, style.width, Text.width(head))]
412
+ end
413
+
414
+ def no_status(path)
415
+ "asterism: no status from #{path} (not there, no status, or no answer in time)"
416
+ end
417
+
418
+ # node/app[/object] painted part by part.
419
+ def paint_path(path)
420
+ parts = path.split("/")
421
+ roles = %i[node app object]
422
+ parts.each_with_index.map { |p, i| paint(p, roles[i] || :object) }.join(paint("/", :note))
423
+ end
424
+
425
+ # An application (a tree's entry) under its name: its status and its
426
+ # objects (how many methods, and the object's description).
427
+ def app_block(name, a, indent)
428
+ pad = " " * indent
429
+ @out.puts "#{pad}#{paint(name, :app)}"
430
+ status_block(a, indent + 2)
431
+ return if a["objects"].empty?
432
+ @out.puts "#{pad} #{paint('objects:', :note)}"
433
+ a["objects"].each do |o|
434
+ ms = o["methods"]
435
+ @out.puts "#{pad} #{paint(o['name'], :object)} #{paint(ms ? count(ms.size, 'method') : '(no meta)', :note)}"
436
+ Text.wrap_text(o["doc"], style.width, first: "#{pad} ").each { @out.puts _1 } if o["doc"]
437
+ end
438
+ end
439
+
440
+ # The status's puts of one application, cleaned (Status.parse).
441
+ def subscribe_status(net, path)
442
+ net.session.subscribe("asterism/#{path}/@status") do |sample|
443
+ got = ::Asterism::Status.parse(sample.key, sample.payload)
444
+ yield got[2] if got
445
+ end
446
+ end
447
+
448
+ # Yields until Ctrl-C, --duration or a lost connection (the seconds
449
+ # left of --duration, or nil).
450
+ def until_stopped(net)
451
+ deadline = @opts[:duration] && (now + @opts[:duration])
452
+ loop do
453
+ left = deadline && (deadline - now)
454
+ break if left && left <= 0
455
+ break unless net.connected?
456
+ yield left
457
+ end
458
+ end
459
+
460
+ def now
461
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
462
+ end
463
+
464
+ # One record of status echo: JSON / YAML, or the text (the status whole
465
+ # the first time, then a line for each change).
466
+ def echo_event(t, path, status, first)
467
+ status = json_safe(status)
468
+ if structured?
469
+ h = { "t" => t.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"), "path" => path, "status" => status }
470
+ h["now"] = true if first
471
+ emit_line(h)
472
+ else
473
+ text = echo_text(t, path, status, first)
474
+ @out.puts text if text
475
+ end
476
+ @out.flush
477
+ end
478
+
479
+ # The text of one record of status echo (nil: no change).
480
+ def echo_text(t, path, status, first)
481
+ head = "#{paint(t.strftime('%H:%M:%S.%L'), :time)} "
482
+ old = @echo_last
483
+ @echo_last = status
484
+ if first || !old.is_a?(Hash) || !status.is_a?(Hash)
485
+ lines = Text.status_lines(status, style.width, Text.width(head))
486
+ return "#{head}#{paint(path, :app)}#{paint(' (now)', :note) if first}\n#{lines.join("\n")}"
487
+ end
488
+ changes = Text.status_diff(old, status)
489
+ return nil if changes.empty?
490
+ changes.map { |c| "#{head}#{change_text(c)}" }.join("\n")
491
+ end
492
+ end
493
+ end
@@ -115,10 +115,12 @@ module Asterism
115
115
 
116
116
  # The tree: [{"node", "profile", "apps" => [{"app", "status",
117
117
  # "objects" => [{"name", "methods"}]}]}]. meta: false leaves the
118
- # methods out (no meta gets).
119
- def tree(meta: true)
120
- objs = objects
121
- app_list = apps
118
+ # methods out (no meta gets); node: (and app:) narrow it to one node
119
+ # (one application).
120
+ def tree(meta: true, node: nil, app: nil)
121
+ scope = node && "#{node}/#{app || '*'}"
122
+ objs = scope ? objects("#{scope}/*") : objects
123
+ app_list = scope ? apps(scope) : apps
122
124
  threads = []
123
125
  node_list = (app_list.map { _1.split("/")[0] } + objs.map { _1.split("/")[0] }).uniq
124
126
  threads << Thread.new { profiles(node_list) }