deg 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. #!/usr/bin/env python
  2. # This file is part of DarkFi (https://dark.fi)
  3. #
  4. # Copyright (C) 2020-2026 Dyne.org foundation
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU Affero General Public License as
  8. # published by the Free Software Foundation, either version 3 of the
  9. # License, or (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU Affero General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU Affero General Public License
  17. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  18. import asyncio, sys, base58
  19. import urwid as u
  20. import networkx as nx
  21. from collections import Counter
  22. from datetime import datetime
  23. import src.rpc
  24. NULL_HASH = '0' * 64
  25. def format_timestamp(timestamp):
  26. ts = int(timestamp)
  27. dt = datetime.fromtimestamp(ts) if ts < 1e10 else datetime.fromtimestamp(int(ts/1000))
  28. return str(dt)
  29. # Rows are rendered newest-first so parents always sit below their children.
  30. # Every active branch owns a fixed 3-character lane. Lanes are assigned in a
  31. # first pass: a node consumes its lane and hands it to one parent, extra
  32. # parents open lanes to the right with a heavy 'M━┑' bend, and when a second
  33. # child shares an already-promised parent an S-bend taps that lane on the
  34. # child's own row: 'o──┘' into a lane on the right, '┌──o' into a lane on the
  35. # left. A shared parent (a fork) is kept aligned with its leftmost child's
  36. # lane, and every parent below it follows that same lane, so a fork never
  37. # indents to the right merely because its right branch was processed first.
  38. # A second pass then draws every row using those resolved lanes.
  39. def render_graph_glyphs(events):
  40. parents_of = {e['hash']: list(dict.fromkeys(
  41. p for p in e['parents'] if p != NULL_HASH)) for e in events}
  42. kids = Counter(p for ps in parents_of.values() for p in ps)
  43. fork = {p for p, c in kids.items() if c > 1}
  44. # First pass: resolve the lane every node will be drawn on. A fork parent
  45. # collects the lanes of all its children and takes the leftmost one.
  46. promised = {}
  47. fork_lanes = {}
  48. fresh = set()
  49. n_lanes = 0
  50. final_lane = {}
  51. for event in events:
  52. node = event['hash']
  53. if node in promised:
  54. lane = promised.pop(node)
  55. elif node in fork_lanes:
  56. lane = min(fork_lanes[node])
  57. else:
  58. lane = n_lanes
  59. n_lanes += 1
  60. final_lane[node] = lane
  61. own = True
  62. for parent in parents_of[node]:
  63. if parent in promised:
  64. continue
  65. if parent in fork:
  66. fork_lanes.setdefault(parent, []).append(lane)
  67. own = False
  68. elif own:
  69. promised[parent] = lane
  70. own = False
  71. else:
  72. promised[parent] = n_lanes
  73. fresh.add((node, parent))
  74. n_lanes += 1
  75. # Second pass: draw the rows using the resolved lanes.
  76. promised = {}
  77. glyphs = []
  78. for event in events:
  79. node = event['hash']
  80. lane = final_lane[node]
  81. promised.pop(node, None)
  82. edges = []
  83. for parent in parents_of[node]:
  84. parent_lane = final_lane[parent]
  85. promised.setdefault(parent, parent_lane)
  86. if parent_lane != lane:
  87. edges.append((parent_lane, (node, parent) in fresh))
  88. grid = [' '] * (3 * n_lanes)
  89. for target in promised.values():
  90. if target != lane:
  91. grid[3 * target + 1] = '│'
  92. for target, is_fresh in edges:
  93. lo, hi = sorted((lane, target))
  94. if is_fresh:
  95. grid[3 * target + 1] = '┑'
  96. run = '━'
  97. elif target > lane:
  98. grid[3 * target + 1] = '┘'
  99. run = '─'
  100. else:
  101. grid[3 * target + 1] = '┌'
  102. run = '─'
  103. for col in range(3 * lo + 2, 3 * hi + 1):
  104. grid[col] = run
  105. grid[3 * lane + 1] = 'M' if len(parents_of[node]) > 1 else 'o'
  106. glyphs.append(''.join(grid).rstrip())
  107. return glyphs
  108. # because tab character is broken in urwid texts
  109. def indent(num):
  110. return " " * (8 - len(str(num)))
  111. class ListItem(u.WidgetWrap):
  112. def __init__ (self, event):
  113. g = event['glyph']
  114. self.content = event
  115. layer_num = int(event["layer"])
  116. layer = "layer " + str(layer_num) + indent(layer_num) if layer_num != 0 else "genesis "
  117. dt = event['hash'][:10] + " │ " + format_timestamp(event['timestamp'])
  118. t = u.AttrMap(u.Text([('word', dt),
  119. ('layer-num', " " + layer),
  120. ('word', g),
  121. ('cont', ' ' + event['content'])], wrap="ellipsis"),
  122. {'word':'datetime', 'layer-num': 'reporter', 'cont': 'content'},
  123. {'word':'event_selected', 'layer-num': 'event_selected', 'cont': 'event_selected'})
  124. u.WidgetWrap.__init__(self, t)
  125. def selectable (self):
  126. return True
  127. def keypress(self, size, key):
  128. return key
  129. # Not event header
  130. class HeaderItem(u.WidgetWrap):
  131. def __init__ (self, info):
  132. dt = format_timestamp(info['timestamp'])
  133. title = f" DAG genesis {info['hash'][:10]} @ {dt}, {info['count']} events "
  134. t = u.AttrMap(u.Text('──' + title + '─' * max(4, 60 - len(title))), 'header')
  135. u.WidgetWrap.__init__(self, t)
  136. def selectable(self):
  137. return False
  138. class ListView(u.WidgetWrap):
  139. def __init__(self):
  140. u.register_signal(self.__class__, ['show_details'])
  141. self.walker = u.SimpleFocusListWalker([])
  142. lb = u.ListBox(self.walker)
  143. u.WidgetWrap.__init__(self, lb)
  144. def modified(self):
  145. focus_w, _ = self.walker.get_focus()
  146. if focus_w is not None:
  147. u.emit_signal(self, 'show_details', focus_w.content)
  148. else:
  149. print("DAG is empty!")
  150. exit(-1)
  151. def set_data(self, items):
  152. events_widgets = [HeaderItem(i) if i.get('type') == 'header' else ListItem(i) for i in items]
  153. u.disconnect_signal(self.walker, 'modified', self.modified)
  154. while len(self.walker) > 0:
  155. self.walker.pop()
  156. self.walker.extend(events_widgets)
  157. u.connect_signal(self.walker, "modified", self.modified)
  158. for idx, widget in enumerate(events_widgets):
  159. if widget.selectable():
  160. self.walker.set_focus(idx)
  161. break
  162. class DetailView(u.WidgetWrap):
  163. def __init__ (self):
  164. t = u.Text("")
  165. u.WidgetWrap.__init__(self, t)
  166. def set_event(self, c):
  167. s = f'Hash: {c["hash"]}\nChildren: {c["children"]}\nParents: {c["parents"]}\nContent: {c["content"]}\nLayer: {c["layer"]}\nTimestamp: {c["timestamp"]}'
  168. self._w.set_text(s)
  169. class App(object):
  170. def unhandled_input(self, key):
  171. if key in ('q', 'Q'):
  172. raise u.ExitMainLoop()
  173. # if key == 'r':
  174. # await self.update_data(self.config)
  175. if key == 'enter':
  176. self.current_view = self.frame2
  177. self.loop.widget = self.frame2
  178. if key in ('b', 'B'):
  179. self.current_view = self.frame1
  180. self.loop.widget = self.frame1
  181. def show_details(self, event):
  182. self.view_two.set_event(event)
  183. def __init__(self):
  184. self.view_one = ListView()
  185. u.connect_signal(self.view_one, 'show_details', self.show_details)
  186. footer = u.AttrWrap(u.Text(" Q to exit"), "footer")
  187. col_rows = u.raw_display.Screen().get_cols_rows()
  188. h = col_rows[0] - 2
  189. f1 = u.Filler(self.view_one, valign='top', height=h)
  190. c_list = u.LineBox(f1, title="Events")
  191. columns = u.Columns([('weight', 100, c_list)])
  192. frame1 = u.AttrMap(u.Frame(body=columns, footer=footer), 'bg')
  193. self.frame1 = frame1
  194. ############
  195. self.view_two = DetailView()
  196. f2 = u.Filler(self.view_two, valign='top')
  197. c_details = u.LineBox(f2, title="Details")
  198. footer = u.AttrWrap(u.Text(" Q to exit, B to main view"), "footer")
  199. columns = u.Columns([('weight', 100, c_details)])
  200. frame2 = u.AttrMap(u.Frame(body=columns, footer=footer), 'bg')
  201. self.frame2 = frame2
  202. ##########
  203. self.current_view = self.frame1 # Start with View One
  204. self.palette = {
  205. ("bg", "white", "black"),
  206. ("event", "white", "black"),
  207. ("event_selected", "white", "light green"),
  208. ('datetime', "light blue", "black"),
  209. ('reporter', "dark green", "black"),
  210. ('content', "", "black"),
  211. ("header", "dark cyan", "black"),
  212. ("footer", "white, bold", "dark red")
  213. }
  214. async def update_data(self, config, replay_mode):
  215. self.config = config
  216. dag_dict = await recreate_dag(config, replay_mode)
  217. if not dag_dict:
  218. print("DAG is empty!")
  219. exit(-1)
  220. parent_child_pairs = []
  221. for node, details in dag_dict.items():
  222. for parent in details['parents']:
  223. if parent == NULL_HASH:
  224. continue
  225. parent_child_pairs.append((parent, node))
  226. # Create a directed graph (genesis events are added explicitly so
  227. # they are never dropped even when nothing references them)
  228. dag = nx.DiGraph()
  229. dag.add_nodes_from(dag_dict.keys())
  230. dag.add_edges_from(parent_child_pairs)
  231. # Partition events into one section per DAG
  232. genesis_nodes = sorted(
  233. (n for n, details in dag_dict.items() if int(details['layer']) == 0),
  234. key=lambda n: int(dag_dict[n]['timestamp']), reverse=True)
  235. sections = []
  236. grouped = set()
  237. for genesis in genesis_nodes:
  238. members = [genesis] + sorted(
  239. n for n in nx.descendants(dag, genesis) if n not in grouped)
  240. grouped.update(members)
  241. sections.append((genesis, members))
  242. # defensive: events unreachable from any known genesis
  243. leftovers = [n for n in dag_dict if n not in grouped]
  244. if leftovers:
  245. root = min(leftovers, key=lambda n: int(dag_dict[n]['layer']))
  246. sections.append((root, leftovers))
  247. items = []
  248. for root, members in sections:
  249. sub = dag.subgraph(members)
  250. order = list(reversed(list(nx.topological_sort(sub))))
  251. l = []
  252. for node in order:
  253. event_details = dag_dict[node] # details
  254. layer = int(event_details['layer'])
  255. content = event_details['content'] # event content
  256. timestamp = event_details['timestamp']
  257. children = list(sub.successors(node))
  258. parents = list(sub.predecessors(node))
  259. try:
  260. raw = base58.b58decode(content)
  261. except ValueError:
  262. raw = content.encode('utf-8', 'replace')
  263. matches = ''.join(ch if ch.isprintable() else ' ' for ch in raw.decode('utf-8', 'replace')).strip()
  264. l.append({"layer":f"{layer}", "hash":f"{node}", "children":children, "parents":parents, "content":f"{matches}", "timestamp": f"{timestamp}"})
  265. glyphs = render_graph_glyphs(l)
  266. for event, glyph in zip(l, glyphs):
  267. event['glyph'] = glyph
  268. items.append({"type":"header", "hash":root, "timestamp":dag_dict[root]['timestamp'], "count":len(order)})
  269. items.extend(l)
  270. self.view_one.set_data(items)
  271. async def start(self, config, replay_mode):
  272. await self.update_data(config, replay_mode)
  273. self.loop = u.MainLoop(self.current_view, self.palette, unhandled_input=self.unhandled_input)
  274. self.loop.run()
  275. async def recreate_dag(config, replay_mode):
  276. host = config['host']
  277. port = config['port']
  278. rpc = src.rpc.JsonRpc()
  279. while True:
  280. try:
  281. await rpc.start(host, port)
  282. break
  283. except OSError:
  284. print(f"Error: Connection Refused to '{host}:{port}', Either because the daemon is down, is currently syncing or wrong url.")
  285. sys.exit(-1)
  286. await rpc.deg_switch(True)
  287. await rpc.deg_switch(False)
  288. if replay_mode:
  289. json_result = await rpc._make_request("eventgraph.replay", [])
  290. else:
  291. json_result = await rpc._make_request("eventgraph.get_info", [])
  292. if json_result['result']['eventgraph_info']:
  293. return json_result['result']['eventgraph_info']['dag']
  294. async def main(argv):
  295. val = str('127.0.0.1:9605')
  296. for i in range(1, len(sys.argv)):
  297. if sys.argv[i] == "-e":
  298. try:
  299. val = sys.argv[i+1]
  300. except IndexError:
  301. print("Please provide a value for \'-e\'")
  302. exit(-1)
  303. break
  304. config = {}
  305. try:
  306. config['host'], config['port'] = val.split(':')
  307. except ValueError:
  308. print("Please provide a port as in: 127.0.0.1:9605")
  309. exit(-1)
  310. replay_mode = False
  311. if len(argv) > 1:
  312. if '-r' in argv:
  313. replay_mode = True
  314. if set(argv) & set(['darkirc', 'irc']):
  315. config['port'] = 9605
  316. elif set(argv) & set(['taud', 'tau']):
  317. config['port'] = 9805
  318. app = App()
  319. await app.start(config, replay_mode)
  320. asyncio.run(main(sys.argv))