Development of an internal social media platform with personalised dashboards for students
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

cursesmon.py 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. # -*- coding: utf-8 -*-
  2. """
  3. celery.events.cursesmon
  4. ~~~~~~~~~~~~~~~~~~~~~~~
  5. Graphical monitor of Celery events using curses.
  6. """
  7. from __future__ import absolute_import, print_function
  8. import curses
  9. import sys
  10. import threading
  11. from datetime import datetime
  12. from itertools import count
  13. from textwrap import wrap
  14. from time import time
  15. from math import ceil
  16. from celery import VERSION_BANNER
  17. from celery import states
  18. from celery.app import app_or_default
  19. from celery.five import items, values
  20. from celery.utils.text import abbr, abbrtask
  21. __all__ = ['CursesMonitor', 'evtop']
  22. BORDER_SPACING = 4
  23. LEFT_BORDER_OFFSET = 3
  24. UUID_WIDTH = 36
  25. STATE_WIDTH = 8
  26. TIMESTAMP_WIDTH = 8
  27. MIN_WORKER_WIDTH = 15
  28. MIN_TASK_WIDTH = 16
  29. # this module is considered experimental
  30. # we don't care about coverage.
  31. STATUS_SCREEN = """\
  32. events: {s.event_count} tasks:{s.task_count} workers:{w_alive}/{w_all}
  33. """
  34. class CursesMonitor(object): # pragma: no cover
  35. keymap = {}
  36. win = None
  37. screen_width = None
  38. screen_delay = 10
  39. selected_task = None
  40. selected_position = 0
  41. selected_str = 'Selected: '
  42. foreground = curses.COLOR_BLACK
  43. background = curses.COLOR_WHITE
  44. online_str = 'Workers online: '
  45. help_title = 'Keys: '
  46. help = ('j:down k:up i:info t:traceback r:result c:revoke ^c: quit')
  47. greet = 'celery events {0}'.format(VERSION_BANNER)
  48. info_str = 'Info: '
  49. def __init__(self, state, app, keymap=None):
  50. self.app = app
  51. self.keymap = keymap or self.keymap
  52. self.state = state
  53. default_keymap = {'J': self.move_selection_down,
  54. 'K': self.move_selection_up,
  55. 'C': self.revoke_selection,
  56. 'T': self.selection_traceback,
  57. 'R': self.selection_result,
  58. 'I': self.selection_info,
  59. 'L': self.selection_rate_limit}
  60. self.keymap = dict(default_keymap, **self.keymap)
  61. self.lock = threading.RLock()
  62. def format_row(self, uuid, task, worker, timestamp, state):
  63. mx = self.display_width
  64. # include spacing
  65. detail_width = mx - 1 - STATE_WIDTH - 1 - TIMESTAMP_WIDTH
  66. uuid_space = detail_width - 1 - MIN_TASK_WIDTH - 1 - MIN_WORKER_WIDTH
  67. if uuid_space < UUID_WIDTH:
  68. uuid_width = uuid_space
  69. else:
  70. uuid_width = UUID_WIDTH
  71. detail_width = detail_width - uuid_width - 1
  72. task_width = int(ceil(detail_width / 2.0))
  73. worker_width = detail_width - task_width - 1
  74. uuid = abbr(uuid, uuid_width).ljust(uuid_width)
  75. worker = abbr(worker, worker_width).ljust(worker_width)
  76. task = abbrtask(task, task_width).ljust(task_width)
  77. state = abbr(state, STATE_WIDTH).ljust(STATE_WIDTH)
  78. timestamp = timestamp.ljust(TIMESTAMP_WIDTH)
  79. row = '{0} {1} {2} {3} {4} '.format(uuid, worker, task,
  80. timestamp, state)
  81. if self.screen_width is None:
  82. self.screen_width = len(row[:mx])
  83. return row[:mx]
  84. @property
  85. def screen_width(self):
  86. _, mx = self.win.getmaxyx()
  87. return mx
  88. @property
  89. def screen_height(self):
  90. my, _ = self.win.getmaxyx()
  91. return my
  92. @property
  93. def display_width(self):
  94. _, mx = self.win.getmaxyx()
  95. return mx - BORDER_SPACING
  96. @property
  97. def display_height(self):
  98. my, _ = self.win.getmaxyx()
  99. return my - 10
  100. @property
  101. def limit(self):
  102. return self.display_height
  103. def find_position(self):
  104. if not self.tasks:
  105. return 0
  106. for i, e in enumerate(self.tasks):
  107. if self.selected_task == e[0]:
  108. return i
  109. return 0
  110. def move_selection_up(self):
  111. self.move_selection(-1)
  112. def move_selection_down(self):
  113. self.move_selection(1)
  114. def move_selection(self, direction=1):
  115. if not self.tasks:
  116. return
  117. pos = self.find_position()
  118. try:
  119. self.selected_task = self.tasks[pos + direction][0]
  120. except IndexError:
  121. self.selected_task = self.tasks[0][0]
  122. keyalias = {curses.KEY_DOWN: 'J',
  123. curses.KEY_UP: 'K',
  124. curses.KEY_ENTER: 'I'}
  125. def handle_keypress(self):
  126. try:
  127. key = self.win.getkey().upper()
  128. except:
  129. return
  130. key = self.keyalias.get(key) or key
  131. handler = self.keymap.get(key)
  132. if handler is not None:
  133. handler()
  134. def alert(self, callback, title=None):
  135. self.win.erase()
  136. my, mx = self.win.getmaxyx()
  137. y = blank_line = count(2)
  138. if title:
  139. self.win.addstr(next(y), 3, title,
  140. curses.A_BOLD | curses.A_UNDERLINE)
  141. next(blank_line)
  142. callback(my, mx, next(y))
  143. self.win.addstr(my - 1, 0, 'Press any key to continue...',
  144. curses.A_BOLD)
  145. self.win.refresh()
  146. while 1:
  147. try:
  148. return self.win.getkey().upper()
  149. except:
  150. pass
  151. def selection_rate_limit(self):
  152. if not self.selected_task:
  153. return curses.beep()
  154. task = self.state.tasks[self.selected_task]
  155. if not task.name:
  156. return curses.beep()
  157. my, mx = self.win.getmaxyx()
  158. r = 'New rate limit: '
  159. self.win.addstr(my - 2, 3, r, curses.A_BOLD | curses.A_UNDERLINE)
  160. self.win.addstr(my - 2, len(r) + 3, ' ' * (mx - len(r)))
  161. rlimit = self.readline(my - 2, 3 + len(r))
  162. if rlimit:
  163. reply = self.app.control.rate_limit(task.name,
  164. rlimit.strip(), reply=True)
  165. self.alert_remote_control_reply(reply)
  166. def alert_remote_control_reply(self, reply):
  167. def callback(my, mx, xs):
  168. y = count(xs)
  169. if not reply:
  170. self.win.addstr(
  171. next(y), 3, 'No replies received in 1s deadline.',
  172. curses.A_BOLD + curses.color_pair(2),
  173. )
  174. return
  175. for subreply in reply:
  176. curline = next(y)
  177. host, response = next(items(subreply))
  178. host = '{0}: '.format(host)
  179. self.win.addstr(curline, 3, host, curses.A_BOLD)
  180. attr = curses.A_NORMAL
  181. text = ''
  182. if 'error' in response:
  183. text = response['error']
  184. attr |= curses.color_pair(2)
  185. elif 'ok' in response:
  186. text = response['ok']
  187. attr |= curses.color_pair(3)
  188. self.win.addstr(curline, 3 + len(host), text, attr)
  189. return self.alert(callback, 'Remote Control Command Replies')
  190. def readline(self, x, y):
  191. buffer = str()
  192. curses.echo()
  193. try:
  194. i = 0
  195. while 1:
  196. ch = self.win.getch(x, y + i)
  197. if ch != -1:
  198. if ch in (10, curses.KEY_ENTER): # enter
  199. break
  200. if ch in (27, ):
  201. buffer = str()
  202. break
  203. buffer += chr(ch)
  204. i += 1
  205. finally:
  206. curses.noecho()
  207. return buffer
  208. def revoke_selection(self):
  209. if not self.selected_task:
  210. return curses.beep()
  211. reply = self.app.control.revoke(self.selected_task, reply=True)
  212. self.alert_remote_control_reply(reply)
  213. def selection_info(self):
  214. if not self.selected_task:
  215. return
  216. def alert_callback(mx, my, xs):
  217. my, mx = self.win.getmaxyx()
  218. y = count(xs)
  219. task = self.state.tasks[self.selected_task]
  220. info = task.info(extra=['state'])
  221. infoitems = [
  222. ('args', info.pop('args', None)),
  223. ('kwargs', info.pop('kwargs', None))
  224. ] + list(info.items())
  225. for key, value in infoitems:
  226. if key is None:
  227. continue
  228. value = str(value)
  229. curline = next(y)
  230. keys = key + ': '
  231. self.win.addstr(curline, 3, keys, curses.A_BOLD)
  232. wrapped = wrap(value, mx - 2)
  233. if len(wrapped) == 1:
  234. self.win.addstr(
  235. curline, len(keys) + 3,
  236. abbr(wrapped[0],
  237. self.screen_width - (len(keys) + 3)))
  238. else:
  239. for subline in wrapped:
  240. nexty = next(y)
  241. if nexty >= my - 1:
  242. subline = ' ' * 4 + '[...]'
  243. elif nexty >= my:
  244. break
  245. self.win.addstr(
  246. nexty, 3,
  247. abbr(' ' * 4 + subline, self.screen_width - 4),
  248. curses.A_NORMAL,
  249. )
  250. return self.alert(
  251. alert_callback, 'Task details for {0.selected_task}'.format(self),
  252. )
  253. def selection_traceback(self):
  254. if not self.selected_task:
  255. return curses.beep()
  256. task = self.state.tasks[self.selected_task]
  257. if task.state not in states.EXCEPTION_STATES:
  258. return curses.beep()
  259. def alert_callback(my, mx, xs):
  260. y = count(xs)
  261. for line in task.traceback.split('\n'):
  262. self.win.addstr(next(y), 3, line)
  263. return self.alert(
  264. alert_callback,
  265. 'Task Exception Traceback for {0.selected_task}'.format(self),
  266. )
  267. def selection_result(self):
  268. if not self.selected_task:
  269. return
  270. def alert_callback(my, mx, xs):
  271. y = count(xs)
  272. task = self.state.tasks[self.selected_task]
  273. result = (getattr(task, 'result', None) or
  274. getattr(task, 'exception', None))
  275. for line in wrap(result or '', mx - 2):
  276. self.win.addstr(next(y), 3, line)
  277. return self.alert(
  278. alert_callback,
  279. 'Task Result for {0.selected_task}'.format(self),
  280. )
  281. def display_task_row(self, lineno, task):
  282. state_color = self.state_colors.get(task.state)
  283. attr = curses.A_NORMAL
  284. if task.uuid == self.selected_task:
  285. attr = curses.A_STANDOUT
  286. timestamp = datetime.utcfromtimestamp(
  287. task.timestamp or time(),
  288. )
  289. timef = timestamp.strftime('%H:%M:%S')
  290. hostname = task.worker.hostname if task.worker else '*NONE*'
  291. line = self.format_row(task.uuid, task.name,
  292. hostname,
  293. timef, task.state)
  294. self.win.addstr(lineno, LEFT_BORDER_OFFSET, line, attr)
  295. if state_color:
  296. self.win.addstr(lineno,
  297. len(line) - STATE_WIDTH + BORDER_SPACING - 1,
  298. task.state, state_color | attr)
  299. def draw(self):
  300. with self.lock:
  301. win = self.win
  302. self.handle_keypress()
  303. x = LEFT_BORDER_OFFSET
  304. y = blank_line = count(2)
  305. my, mx = win.getmaxyx()
  306. win.erase()
  307. win.bkgd(' ', curses.color_pair(1))
  308. win.border()
  309. win.addstr(1, x, self.greet, curses.A_DIM | curses.color_pair(5))
  310. next(blank_line)
  311. win.addstr(next(y), x, self.format_row('UUID', 'TASK',
  312. 'WORKER', 'TIME', 'STATE'),
  313. curses.A_BOLD | curses.A_UNDERLINE)
  314. tasks = self.tasks
  315. if tasks:
  316. for row, (uuid, task) in enumerate(tasks):
  317. if row > self.display_height:
  318. break
  319. if task.uuid:
  320. lineno = next(y)
  321. self.display_task_row(lineno, task)
  322. # -- Footer
  323. next(blank_line)
  324. win.hline(my - 6, x, curses.ACS_HLINE, self.screen_width - 4)
  325. # Selected Task Info
  326. if self.selected_task:
  327. win.addstr(my - 5, x, self.selected_str, curses.A_BOLD)
  328. info = 'Missing extended info'
  329. detail = ''
  330. try:
  331. selection = self.state.tasks[self.selected_task]
  332. except KeyError:
  333. pass
  334. else:
  335. info = selection.info()
  336. if 'runtime' in info:
  337. info['runtime'] = '{0:.2f}'.format(info['runtime'])
  338. if 'result' in info:
  339. info['result'] = abbr(info['result'], 16)
  340. info = ' '.join(
  341. '{0}={1}'.format(key, value)
  342. for key, value in items(info)
  343. )
  344. detail = '... -> key i'
  345. infowin = abbr(info,
  346. self.screen_width - len(self.selected_str) - 2,
  347. detail)
  348. win.addstr(my - 5, x + len(self.selected_str), infowin)
  349. # Make ellipsis bold
  350. if detail in infowin:
  351. detailpos = len(infowin) - len(detail)
  352. win.addstr(my - 5, x + len(self.selected_str) + detailpos,
  353. detail, curses.A_BOLD)
  354. else:
  355. win.addstr(my - 5, x, 'No task selected', curses.A_NORMAL)
  356. # Workers
  357. if self.workers:
  358. win.addstr(my - 4, x, self.online_str, curses.A_BOLD)
  359. win.addstr(my - 4, x + len(self.online_str),
  360. ', '.join(sorted(self.workers)), curses.A_NORMAL)
  361. else:
  362. win.addstr(my - 4, x, 'No workers discovered.')
  363. # Info
  364. win.addstr(my - 3, x, self.info_str, curses.A_BOLD)
  365. win.addstr(
  366. my - 3, x + len(self.info_str),
  367. STATUS_SCREEN.format(
  368. s=self.state,
  369. w_alive=len([w for w in values(self.state.workers)
  370. if w.alive]),
  371. w_all=len(self.state.workers),
  372. ),
  373. curses.A_DIM,
  374. )
  375. # Help
  376. self.safe_add_str(my - 2, x, self.help_title, curses.A_BOLD)
  377. self.safe_add_str(my - 2, x + len(self.help_title), self.help,
  378. curses.A_DIM)
  379. win.refresh()
  380. def safe_add_str(self, y, x, string, *args, **kwargs):
  381. if x + len(string) > self.screen_width:
  382. string = string[:self.screen_width - x]
  383. self.win.addstr(y, x, string, *args, **kwargs)
  384. def init_screen(self):
  385. with self.lock:
  386. self.win = curses.initscr()
  387. self.win.nodelay(True)
  388. self.win.keypad(True)
  389. curses.start_color()
  390. curses.init_pair(1, self.foreground, self.background)
  391. # exception states
  392. curses.init_pair(2, curses.COLOR_RED, self.background)
  393. # successful state
  394. curses.init_pair(3, curses.COLOR_GREEN, self.background)
  395. # revoked state
  396. curses.init_pair(4, curses.COLOR_MAGENTA, self.background)
  397. # greeting
  398. curses.init_pair(5, curses.COLOR_BLUE, self.background)
  399. # started state
  400. curses.init_pair(6, curses.COLOR_YELLOW, self.foreground)
  401. self.state_colors = {states.SUCCESS: curses.color_pair(3),
  402. states.REVOKED: curses.color_pair(4),
  403. states.STARTED: curses.color_pair(6)}
  404. for state in states.EXCEPTION_STATES:
  405. self.state_colors[state] = curses.color_pair(2)
  406. curses.cbreak()
  407. def resetscreen(self):
  408. with self.lock:
  409. curses.nocbreak()
  410. self.win.keypad(False)
  411. curses.echo()
  412. curses.endwin()
  413. def nap(self):
  414. curses.napms(self.screen_delay)
  415. @property
  416. def tasks(self):
  417. return list(self.state.tasks_by_time(limit=self.limit))
  418. @property
  419. def workers(self):
  420. return [hostname for hostname, w in items(self.state.workers)
  421. if w.alive]
  422. class DisplayThread(threading.Thread): # pragma: no cover
  423. def __init__(self, display):
  424. self.display = display
  425. self.shutdown = False
  426. threading.Thread.__init__(self)
  427. def run(self):
  428. while not self.shutdown:
  429. self.display.draw()
  430. self.display.nap()
  431. def capture_events(app, state, display): # pragma: no cover
  432. def on_connection_error(exc, interval):
  433. print('Connection Error: {0!r}. Retry in {1}s.'.format(
  434. exc, interval), file=sys.stderr)
  435. while 1:
  436. print('-> evtop: starting capture...', file=sys.stderr)
  437. with app.connection() as conn:
  438. try:
  439. conn.ensure_connection(on_connection_error,
  440. app.conf.BROKER_CONNECTION_MAX_RETRIES)
  441. recv = app.events.Receiver(conn, handlers={'*': state.event})
  442. display.resetscreen()
  443. display.init_screen()
  444. recv.capture()
  445. except conn.connection_errors + conn.channel_errors as exc:
  446. print('Connection lost: {0!r}'.format(exc), file=sys.stderr)
  447. def evtop(app=None): # pragma: no cover
  448. app = app_or_default(app)
  449. state = app.events.State()
  450. display = CursesMonitor(state, app)
  451. display.init_screen()
  452. refresher = DisplayThread(display)
  453. refresher.start()
  454. try:
  455. capture_events(app, state, display)
  456. except Exception:
  457. refresher.shutdown = True
  458. refresher.join()
  459. display.resetscreen()
  460. raise
  461. except (KeyboardInterrupt, SystemExit):
  462. refresher.shutdown = True
  463. refresher.join()
  464. display.resetscreen()
  465. if __name__ == '__main__': # pragma: no cover
  466. evtop()