cursesmon.py 16 KB

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