hubeye 0.3.0 → 0.3.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.
@@ -0,0 +1,686 @@
1
+ require "hubeye/shared/hubeye_protocol"
2
+ require "hubeye/log/logger"
3
+ require "hubeye/helpers/time"
4
+
5
+ include Hubeye::Helpers::Time
6
+ include Hubeye::Log
7
+
8
+ module Hubeye
9
+ module Server
10
+ attr_accessor :remote_connection
11
+ attr_reader :socket, :sockets, :session, :daemonized
12
+
13
+ require 'yaml'
14
+ require 'json'
15
+ require 'open-uri'
16
+ require 'forwardable'
17
+
18
+ require_relative "commit"
19
+ require_relative "session"
20
+
21
+ require "hubeye/config/parser"
22
+ require "hubeye/notification/finder"
23
+ require "hubeye/hooks/git_hooks"
24
+ require "hubeye/hooks/executer"
25
+
26
+ CONFIG_FILE = File.join(ENV['HOME'], ".hubeye", "hubeyerc")
27
+ CONFIG = {}
28
+
29
+ # CONFIG options: defined in ~/.hubeye/hubeyerc
30
+ #
31
+ # Option overview:
32
+ #
33
+ # CONFIG[:oncearound]: 60 (seconds) is the default amount of time for looking
34
+ # for changes in every single repository. If tracking lots of repos,
35
+ # it might be a good idea to increase the value, or hubeye will cry
36
+ # due to overwork, fatigue and general anhedonia.
37
+ #
38
+ # hubeyerc format => oncearound: 1000
39
+ #
40
+ # CONFIG[:username] is the username used when not specified.
41
+ # hubeyerc format => username: 'hansolo'
42
+ # when set to 'hansolo'
43
+ # >rails
44
+ # would track https://www.github.com/hansolo/rails
45
+ # but a full URI path won't use CONFIG[:username]
46
+ # >rails/rails
47
+ # would track https://www.github.com/rails/rails
48
+ Config::Parser.new(CONFIG_FILE) do |c|
49
+ CONFIG[:username] = c.username ||
50
+ `git config --get-regexp github`.split(' ').last || ''
51
+ CONFIG[:oncearound] = c.oncearound || 60
52
+ CONFIG[:load_repos] = c.load_repos || []
53
+ CONFIG[:load_hooks] = c.load_hooks || []
54
+ CONFIG[:default_track] = c.default_track || []
55
+
56
+ CONFIG[:notification_wanted] = if c.notification_wanted.nil?
57
+ true
58
+ else
59
+ c.notification_wanted
60
+ end
61
+ end
62
+
63
+ if CONFIG[:notification_wanted]
64
+ CONFIG[:desktop_notification] =
65
+ Notification::Finder.find_notify
66
+ end
67
+
68
+ class Exit
69
+ def call
70
+ socket = server.socket
71
+ session = server.session
72
+ socket.deliver "Bye!"
73
+ # mark the session as continuous to not wipe the log file
74
+ session.continuous = true
75
+ Logger.log "Closing connection to #{socket.peeraddr[2]}"
76
+ server.remote_connection = false
77
+ unless session.tracker.empty?
78
+ Logger.log "Tracking: #{session.tracker.keys.join ', '}"
79
+ end
80
+ Logger.log ""
81
+ server.sockets.delete(socket)
82
+ socket.close
83
+ end
84
+ end
85
+
86
+ class Shutdown
87
+ def call
88
+ socket = server.socket
89
+ Logger.log "Closing connection to #{socket.peeraddr[2]}"
90
+ Logger.log "Shutting down... (#{NOW})"
91
+ Logger.log ""
92
+ Logger.log ""
93
+ socket.deliver "Shutting down server"
94
+ server.sockets.delete(socket)
95
+ socket.close
96
+ unless server.daemonized
97
+ STDOUT.puts "Shutting down gracefully."
98
+ end
99
+ exit 0
100
+ end
101
+ end
102
+
103
+ class SaveHook
104
+ def call
105
+ socket = server.socket
106
+ hooks = server.session.hooks
107
+ if !hooks.empty?
108
+ file = "#{ENV['HOME']}/.hubeye/hooks/#{@matches[2]}.yml"
109
+ if File.exists? file
110
+ override?
111
+ end
112
+ File.open(file, "w") do |f_out|
113
+ ::YAML.dump(hooks, f_out)
114
+ end
115
+ socket.deliver "Saved hook#{@matches[1]} as #{@matches[2]}"
116
+ else
117
+ socket.deliver "No hook#{@matches[1]} to save"
118
+ end
119
+ end
120
+
121
+ private
122
+ def override?
123
+ end
124
+ end
125
+
126
+ class SaveRepo
127
+ def call
128
+ socket = server.socket
129
+ if !server.session.tracker.empty?
130
+ file = "#{ENV['HOME']}/.hubeye/repos/#{@matches[2]}.yml"
131
+ if File.exists? file
132
+ override?
133
+ end
134
+ # dump only the repository names, not the shas
135
+ File.open(file, "w") do |f_out|
136
+ ::YAML.dump(server.session.tracker.keys, f_out)
137
+ end
138
+ socket.deliver "Saved repo#{@matches[1]} as #{@matches[2]}"
139
+ else
140
+ socket.deliver "No remote repos are being tracked"
141
+ end
142
+ end
143
+
144
+ private
145
+ def override?
146
+ end
147
+ end
148
+
149
+ class LoadHook
150
+ def call
151
+ socket = server.socket
152
+ if _t = @options[:internal]
153
+ @silent = _t
154
+ end
155
+ hookfile = "#{ENV['HOME']}/.hubeye/hooks/#{@matches[2]}.yml"
156
+ new_hooks = nil
157
+ if File.exists?(hookfile)
158
+ File.open(hookfile) do |f|
159
+ new_hooks = ::YAML.load(f)
160
+ end
161
+ # need to fix this to check if there are already commands for that
162
+ # repo
163
+ server.session.hooks.merge!(new_hooks)
164
+ unless @silent
165
+ socket.deliver "Loaded #{@matches[1]} #{@matches[2]}"
166
+ end
167
+ else
168
+ unless @silent
169
+ socket.deliver "No #{@matches[1]} file to load from"
170
+ end
171
+ end
172
+ end
173
+ end
174
+
175
+ class LoadRepo
176
+ def call
177
+ socket = server.socket
178
+ if _t = @options[:internal]
179
+ @silent = _t
180
+ end
181
+ if File.exists?(repo_file = "#{ENV['HOME']}/.hubeye/repos/#{@matches[2]}.yml")
182
+ new_repos = nil
183
+ File.open(repo_file) do |f|
184
+ new_repos = ::YAML.load(f)
185
+ end
186
+ if !new_repos
187
+ socket.deliver "Unable to load #{@matches[2]}: empty file" unless @silent
188
+ return
189
+ end
190
+ new_repos.each do |r|
191
+ # add the repo name to the hubeye tracker
192
+ commit = server.track(r)
193
+ server.session.tracker.add_or_replace!(commit.repo, commit.sha)
194
+ end
195
+ unless @silent
196
+ socket.deliver "Loaded #{@matches[2]}.\nTracking:\n#{server.session.tracker.keys.join ', '}"
197
+ end
198
+ else
199
+ socket.deliver "No file to load from" unless @silent
200
+ end
201
+ end
202
+ end
203
+
204
+ class AddHook
205
+ def call
206
+ socket = server.socket
207
+ cwd = File.expand_path('.')
208
+ repo = @matches[1]
209
+ _dir = @matches[3]
210
+ cmd = @matches[4]
211
+ hooks = server.session.hooks
212
+ if repo.nil? and cmd.nil?
213
+ socket.deliver "Format: 'hook add user/repo [dir: /my/dir/repo ] cmd: some_cmd'"
214
+ return
215
+ end
216
+ if hooks[repo]
217
+ _dir ? dir = _dir : dir = cwd
218
+ if hooks[repo][dir]
219
+ hooks[repo][dir] << cmd
220
+ else
221
+ hooks[repo][dir] = [cmd]
222
+ end
223
+ else
224
+ dir = _dir || cwd
225
+ hooks[repo] = {dir => [cmd]}
226
+ end
227
+ socket.deliver "Hook added"
228
+ end
229
+ end
230
+
231
+ class ListHooks
232
+ def call
233
+ socket = server.socket
234
+ hooks = server.session.hooks
235
+ if hooks.empty?
236
+ socket.deliver "No hooks"
237
+ return
238
+ end
239
+ pwd = File.expand_path('.')
240
+ format_string = ""
241
+ hooks.each do |repo, hash|
242
+ local_dir = nil
243
+ command = nil
244
+ hash.each do |dir,cmd|
245
+ if dir.nil?
246
+ local_dir = pwd
247
+ command = cmd.join("\n" + (' ' * 8))
248
+ else
249
+ command = cmd
250
+ local_dir = dir
251
+ end
252
+ end
253
+ format_string << <<EOS
254
+ remote: #{repo}
255
+ dir: #{local_dir}
256
+ cmds: #{command}\n
257
+ EOS
258
+ end
259
+ socket.deliver format_string
260
+ end
261
+ end
262
+
263
+ class ListTracking
264
+ def call
265
+ socket = server.socket
266
+ tracker = server.session.tracker
267
+ output = ''
268
+ if @options[:details]
269
+ commit_list = []
270
+ tracker.keys.each do |repo|
271
+ commit = server.track(repo, :list => true)
272
+ commit_list << commit
273
+ end
274
+ commit_list.each do |c|
275
+ output << c.repo + "\n"
276
+ underline = '=' * c.repo.length
277
+ output << underline + "\n\n"
278
+ output << c.commit_message + "\n=> " +
279
+ c.committer_name + "\n"
280
+ output << "\n" unless c.repo == commit_list.last.repo
281
+ end
282
+ else
283
+ output << tracker.keys.join(', ')
284
+ end
285
+ output = "none" if output.empty?
286
+ socket.deliver output
287
+ end
288
+ end
289
+
290
+ class Next
291
+ def call
292
+ server.socket.deliver ""
293
+ end
294
+ end
295
+
296
+ class RmRepo
297
+ def call
298
+ socket = server.socket
299
+ session = server.session
300
+ username = session.username
301
+ repo_name = session.repo_name
302
+ m1 = @matches[1]
303
+ if m1.include?('/')
304
+ username, repo_name = m1.split('/')
305
+ else
306
+ repo_name = m1
307
+ end
308
+ full_repo_name = "#{username}/#{repo_name}"
309
+ rm = session.tracker.delete(full_repo_name)
310
+ if rm
311
+ socket.deliver "Stopped watching repository #{full_repo_name}"
312
+ else
313
+ socket.deliver "Repository #{full_repo_name} not currently being watched"
314
+ end
315
+ end
316
+ end
317
+
318
+ class AddRepo
319
+ def call
320
+ session = server.session
321
+ if @options and @options[:fullpath]
322
+ session.username, session.repo_name = input.split('/')
323
+ else
324
+ session.repo_name = input
325
+ end
326
+ add_repo
327
+ end
328
+
329
+ private
330
+ def add_repo
331
+ socket = server.socket
332
+ session = server.session
333
+ full_repo_name = "#{session.username}/#{session.repo_name}"
334
+ commit = server.track(full_repo_name, :latest => true)
335
+ new_sha = commit.sha
336
+ commit_msg = commit.commit_message
337
+ committer = commit.committer_name
338
+ msg = "#{commit_msg}\n=> #{committer}"
339
+ change = session.tracker.add_or_replace!(full_repo_name, new_sha)
340
+ # new repo to track
341
+ if !change
342
+ socket.deliver "Repository #{full_repo_name} has not changed"
343
+ return
344
+ elsif change[:add]
345
+ # log the fact that the user added a repo to be tracked
346
+ Logger.log("Added to tracker: #{full_repo_name} (#{NOW})")
347
+ # show the user, via the client, the info and commit msg for the commit
348
+ socket.deliver msg
349
+ elsif change[:replace]
350
+ change_msg = "New commit on #{full_repo_name}\n"
351
+ change_msg << msg
352
+ socket.deliver change_msg
353
+ if server.daemonized
354
+ Logger.log_change(full_repo_name, commit_msg, committer)
355
+ else
356
+ Logger.log_change(full_repo_name, commit_msg, committer,
357
+ :include_terminal => true)
358
+ end
359
+ end
360
+ end
361
+ end
362
+
363
+ class Strategy
364
+ attr_reader :server, :input
365
+
366
+ UnknownStrategy = Class.new(StandardError)
367
+ extend Forwardable
368
+ def_delegator :@server, :socket
369
+
370
+ def initialize(server, options={})
371
+ @server = server
372
+ opts = {:internal_input => nil}.merge options
373
+ invalid_input = lambda {
374
+ @server.remote_connection = false
375
+ throw(:invalid_input)
376
+ }
377
+
378
+ if !opts[:internal_input]
379
+ begin
380
+ @input = socket.read_all
381
+ rescue => e
382
+ STDOUT.puts e
383
+ invalid_input.call
384
+ end
385
+ # check if the client pressed ^C or ^D
386
+ if @input.nil?
387
+ invalid_input.call
388
+ end
389
+ else
390
+ @input = opts[:internal_input]
391
+ end
392
+ @input = @input.strip.downcase
393
+ @input.gsub! /diiv/, '/'
394
+ end
395
+
396
+ STRATEGY_CLASSES = [ "Shutdown", "Exit", "SaveHook", "SaveRepo",
397
+ "LoadHook", "LoadRepo", "AddHook", "ListHooks", "ListTracking",
398
+ "Next", "RmRepo", "AddRepo" ]
399
+
400
+ STRATEGY_CLASSES.each do |klass_str|
401
+ klass = eval "::Hubeye::Server::#{klass_str}"
402
+ klass.class_eval do
403
+ extend Forwardable
404
+ def_delegators :@strategy, :input, :server
405
+ def initialize matches, strategy, options={}
406
+ @matches = matches
407
+ @strategy = strategy
408
+ @options = options
409
+ call
410
+ end
411
+ end
412
+ end
413
+
414
+ # strategy classes
415
+
416
+ # STRATEGIES hash
417
+ # ===============
418
+ # keys: input matches
419
+ # OR
420
+ # lambda {|input| input.something?} => value
421
+ #
422
+ # values: lambda {|matchdata, basestrategy| SomeStrategy.new(matchdata, basestrategy)}
423
+ STRATEGIES = {
424
+ %r{\Ashutdown\Z} => lambda {|m, s| Shutdown.new(m, s)},
425
+ %r{\Aquit|exit\Z} => lambda {|m, s| Exit.new(m, s)},
426
+ %r{\Atracking\s*\Z} => lambda {|m, s| ListTracking.new(m, s)},
427
+ %r{\Atracking\s*-d\Z} => lambda {|m, s| ListTracking.new(m, s, :details => true)},
428
+ %r{\A\s*save hook(s?) as (.+)\Z} => lambda {|m, s| SaveHook.new(m, s)},
429
+ %r{\A\s*save repo(s?) as (.+)\Z} => lambda {|m, s| SaveRepo.new(m, s)},
430
+ %r{\A\s*load hook(s?) (.+)\Z} => lambda {|m, s| LoadHook.new(m, s)},
431
+ %r{\A\s*load repo(s?) (.+)\Z} => lambda {|m, s| LoadRepo.new(m, s)},
432
+ %r{\A\s*internal load hook(s?) (.+)\Z} => lambda {|m, s| LoadHook.new(m, s, :internal => true)},
433
+ %r{\A\s*internal load repo(s?) (.+)\Z} => lambda {|m, s| LoadRepo.new(m, s, :internal => true)},
434
+ %r{\Ahook add ([-\w]+/[-\w]+) (dir:\s?(.*))?\s*cmd:\s?(.*)\Z} => lambda {|m, s| AddHook.new(m, s)},
435
+ %r{\Ahook list\Z} => lambda {|m, s| ListHooks.new(m, s)},
436
+ %r{^\s*$} => lambda {|m, s| Next.new(m, s)},
437
+ %r{\Arm ([-\w]+/?[-\w]*)\Z} => lambda {|m, s| RmRepo.new(m, s)},
438
+ lambda {|inp| inp.include? '/'} => lambda {|m, s| AddRepo.new(m, s, :fullpath => true)},
439
+ lambda {|inp| not inp.nil?} => lambda {|m, s| AddRepo.new(m, s)}
440
+ }
441
+
442
+ def call
443
+ STRATEGIES.each do |inp,strat|
444
+ if inp.respond_to? :match
445
+ if m = @input.match(inp)
446
+ return strat.call(m, self)
447
+ end
448
+ elsif inp.respond_to? :call
449
+ if m = inp.call(@input)
450
+ return strat.call(m, self)
451
+ end
452
+ end
453
+ end
454
+ raise UnknownStrategy
455
+ end
456
+ end # end of Strategy
457
+
458
+
459
+ def start(port, options={})
460
+ listen(port)
461
+ setup_env(options)
462
+ loop do
463
+ waiting = catch(:connect) do
464
+ look_for_changes unless @remote_connection
465
+ end
466
+ client_connect(@sockets) if waiting
467
+ catch(:invalid_input) do
468
+ strategy = Strategy.new(self)
469
+ strategy.call
470
+ end
471
+ @session.cleanup
472
+ end
473
+ end
474
+
475
+ #TODO: refactor this into its own class. Getting unwieldy.
476
+ # The track method closes over a list variable to store recent info on
477
+ # tracked repositories.
478
+ # Options: :sha, :latest, :full, :list (all boolean).
479
+ # The :list => true option gives back the commit object from the list.
480
+ list = []
481
+
482
+ define_method :track do |repo, options={}|
483
+ if repo.include? '/'
484
+ username, repo_name = repo.split '/'
485
+ full_repo_name = repo
486
+ else
487
+ username, repo_name = @session.username, repo
488
+ full_repo_name = "#{username}/#{repo_name}"
489
+ end
490
+ unless options[:list]
491
+ hist = nil
492
+ begin
493
+ open "https://api.github.com/repos/#{username}/" \
494
+ "#{repo_name}/commits" do |f|
495
+ hist = JSON.parse f.read
496
+ end
497
+ rescue
498
+ @socket.deliver "Not a Github repository name"
499
+ throw(:invalid_input)
500
+ end
501
+ new_info =
502
+ {full_repo_name =>
503
+ {'sha' => hist.first['sha'],
504
+ 'commit' =>
505
+ {'message' => hist.first['commit']['message'],
506
+ 'committer' => {'name' => hist.first['commit']['committer']['name']}
507
+ }
508
+ }
509
+ }
510
+ commit = Commit.new(new_info)
511
+ # update the list
512
+ list.reject! {|cmt| cmt.repo == full_repo_name}
513
+ list << commit
514
+ end
515
+ if options[:full]
516
+ # unsupported so far
517
+ raise ArgumentError.new
518
+ elsif options[:latest]
519
+ commit.dup
520
+ elsif options[:list]
521
+ list.each {|c| return c if c.repo == full_repo_name}
522
+ nil
523
+ else
524
+ Commit.new full_repo_name => {'sha' => hist.first['sha']}
525
+ end
526
+ end
527
+
528
+ private
529
+ def listen(port)
530
+ @server = TCPServer.open(port)
531
+ end
532
+
533
+ def setup_env(options={})
534
+ @remote_connection = false
535
+ @daemonized = options[:daemon]
536
+ @sockets = [@server] # An array of sockets we'll monitor
537
+ trap_signals 'INT', 'KILL'
538
+ @session = Session.new
539
+ @session.username = CONFIG[:username]
540
+ unless CONFIG[:default_track].empty?
541
+ repos = CONFIG[:default_track].dup
542
+ repos.each do |repo|
543
+ commit = track(repo)
544
+ @session.tracker.add_or_replace! commit.repo => commit.sha
545
+ end
546
+ end
547
+ unless CONFIG[:load_hooks].empty?
548
+ hooks = CONFIG[:load_hooks].dup
549
+ session_load :hooks => hooks
550
+ end
551
+ unless CONFIG[:load_repos].empty?
552
+ repos = CONFIG[:load_repos].dup
553
+ session_load :repos => repos
554
+ end
555
+ end
556
+
557
+ def trap_signals *sigs
558
+ sigs.each do |sig|
559
+ trap(sig) do
560
+ @sockets.each {|s| s.close}
561
+ STDOUT.puts
562
+ exit 1
563
+ end
564
+ end
565
+ end
566
+
567
+ def session_load options={}
568
+ opts = {:hooks => nil, :repos => nil}.merge options
569
+ if hooks = opts[:hooks]
570
+ hooks.each do |h|
571
+ strat = Strategy.new(self, :internal_input => "internal load hook #{h}")
572
+ strat.call
573
+ end
574
+ elsif repos = opts[:repos]
575
+ repos.each do |r|
576
+ strat = Strategy.new(self, :internal_input => "internal load repo #{r}")
577
+ strat.call
578
+ end
579
+ else
580
+ raise ArgumentError.new "Must load either hooks or repos"
581
+ end
582
+ end
583
+
584
+ #TODO: refactor this ugly, long method into a new class
585
+ def look_for_changes
586
+ # if no client is connected, and the tracker hash is non-empty
587
+ if @sockets.size == 1 and !@session.tracker.empty?
588
+ loop do
589
+ sleep_amt = CONFIG[:oncearound] / @session.tracker.length
590
+ @session.tracker.each do |repo,sha|
591
+ start_time = Time.now
592
+ commit = track(repo, :latest => true)
593
+ api_time = (Time.now - start_time).to_i
594
+ if commit.sha == sha
595
+ (sleep_amt - api_time).times do
596
+ sleep 1
597
+ @remote_connection = client_ready(@sockets) ? true : false
598
+ throw(:connect, true) if @remote_connection
599
+ end
600
+ else
601
+ # There was a change to a tracked repository.
602
+ full_repo_name = commit.repo
603
+ commit_msg = commit.commit_message
604
+ committer = commit.committer_name
605
+ new_sha = commit.sha
606
+ change_msg = "Repo #{full_repo_name} has changed\nNew commit: " \
607
+ "#{commit_msg}\n=> #{committer}"
608
+ case CONFIG[:desktop_notification]
609
+ when "libnotify"
610
+ Notification::GnomeNotify.notify("Hubeye", change_msg)
611
+ when "growl"
612
+ Autotest::Growl.growl("Hubeye", change_msg)
613
+ when nil
614
+ unless @daemonized
615
+ Logger.log_change(full_repo_name, commit_msg, committer, :include_terminal => true)
616
+ already_logged = true
617
+ end
618
+ end
619
+ Logger.log_change(full_repo_name, commit_msg, committer) unless
620
+ already_logged
621
+ # execute any hooks for that repository
622
+ unless @session.hooks.empty?
623
+ if hooks = @session.hooks[full_repo_name]
624
+ hooks.each do |dir,cmds|
625
+ Hooks::Command.execute(cmds, :directory => dir, :repo => full_repo_name)
626
+ end
627
+ end
628
+ end
629
+ @session.tracker.add_or_replace!(full_repo_name, new_sha)
630
+ end
631
+ end
632
+ end # end of loop
633
+ else
634
+ @remote_connection = client_ready(@sockets, :block => true) ? true : false
635
+ throw(:connect, true) if @remote_connection
636
+ end
637
+ end
638
+
639
+ def client_ready(sockets, options={})
640
+ if options[:block]
641
+ select(sockets, nil, nil)
642
+ else
643
+ select(sockets, nil, nil, 1)
644
+ end
645
+ end
646
+
647
+ def client_connect(sockets)
648
+ ready = select(sockets)
649
+ readable = ready[0]
650
+ readable.each do |socket|
651
+ if socket == @server
652
+ @socket = @server.accept
653
+ @socket.sync = false
654
+ sockets << @socket
655
+ # Inform the client of connection
656
+ basic_inform = "Hubeye running on #{Socket.gethostname} as #{@session.username}"
657
+ if !@session.tracker.empty?
658
+ @socket.deliver "#{basic_inform}\nTracking: #{@session.tracker.keys.join ', '}"
659
+ else
660
+ @socket.deliver basic_inform
661
+ end
662
+ puts "Client connected at #{NOW}" unless @daemonized
663
+ if @session.continuous
664
+ Logger.log "Accepted connection from #{@socket.peeraddr[2]} (#{NOW})"
665
+ else
666
+ # wipe the log file and start fresh
667
+ Logger.relog "Accepted connection from #{@socket.peeraddr[2]} (#{NOW})"
668
+ end
669
+ Logger.log "local: #{@socket.addr}"
670
+ Logger.log "peer : #{@socket.peeraddr}"
671
+ end
672
+ end
673
+ end
674
+
675
+ class Server
676
+ include ::Hubeye::Server
677
+
678
+ def initialize(debug=true)
679
+ @debug = debug
680
+ end
681
+ end
682
+
683
+ end # of Server module
684
+
685
+ end # end of Hubeye module
686
+