| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377 |
- #!/usr/bin/env python
- # This file is part of DarkFi (https://dark.fi)
- #
- # Copyright (C) 2020-2026 Dyne.org foundation
- #
- # This program is free software: you can redistribute it and/or modify
- # it under the terms of the GNU Affero General Public License as
- # published by the Free Software Foundation, either version 3 of the
- # License, or (at your option) any later version.
- #
- # This program is distributed in the hope that it will be useful,
- # but WITHOUT ANY WARRANTY; without even the implied warranty of
- # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- # GNU Affero General Public License for more details.
- #
- # You should have received a copy of the GNU Affero General Public License
- # along with this program. If not, see <https://www.gnu.org/licenses/>.
- import asyncio, sys, base58
- import urwid as u
- import networkx as nx
- from collections import Counter
- from datetime import datetime
- import src.rpc
- NULL_HASH = '0' * 64
- def format_timestamp(timestamp):
- ts = int(timestamp)
- dt = datetime.fromtimestamp(ts) if ts < 1e10 else datetime.fromtimestamp(int(ts/1000))
- return str(dt)
- # Rows are rendered newest-first so parents always sit below their children.
- # Every active branch owns a fixed 3-character lane. Lanes are assigned in a
- # first pass: a node consumes its lane and hands it to one parent, extra
- # parents open lanes to the right with a heavy 'M━┑' bend, and when a second
- # child shares an already-promised parent an S-bend taps that lane on the
- # child's own row: 'o──┘' into a lane on the right, '┌──o' into a lane on the
- # left. A shared parent (a fork) is kept aligned with its leftmost child's
- # lane, and every parent below it follows that same lane, so a fork never
- # indents to the right merely because its right branch was processed first.
- # A second pass then draws every row using those resolved lanes.
- def render_graph_glyphs(events):
- parents_of = {e['hash']: list(dict.fromkeys(
- p for p in e['parents'] if p != NULL_HASH)) for e in events}
- kids = Counter(p for ps in parents_of.values() for p in ps)
- fork = {p for p, c in kids.items() if c > 1}
- # First pass: resolve the lane every node will be drawn on. A fork parent
- # collects the lanes of all its children and takes the leftmost one.
- promised = {}
- fork_lanes = {}
- fresh = set()
- n_lanes = 0
- final_lane = {}
- for event in events:
- node = event['hash']
- if node in promised:
- lane = promised.pop(node)
- elif node in fork_lanes:
- lane = min(fork_lanes[node])
- else:
- lane = n_lanes
- n_lanes += 1
- final_lane[node] = lane
- own = True
- for parent in parents_of[node]:
- if parent in promised:
- continue
- if parent in fork:
- fork_lanes.setdefault(parent, []).append(lane)
- own = False
- elif own:
- promised[parent] = lane
- own = False
- else:
- promised[parent] = n_lanes
- fresh.add((node, parent))
- n_lanes += 1
- # Second pass: draw the rows using the resolved lanes.
- promised = {}
- glyphs = []
- for event in events:
- node = event['hash']
- lane = final_lane[node]
- promised.pop(node, None)
- edges = []
- for parent in parents_of[node]:
- parent_lane = final_lane[parent]
- promised.setdefault(parent, parent_lane)
- if parent_lane != lane:
- edges.append((parent_lane, (node, parent) in fresh))
- grid = [' '] * (3 * n_lanes)
- for target in promised.values():
- if target != lane:
- grid[3 * target + 1] = '│'
- for target, is_fresh in edges:
- lo, hi = sorted((lane, target))
- if is_fresh:
- grid[3 * target + 1] = '┑'
- run = '━'
- elif target > lane:
- grid[3 * target + 1] = '┘'
- run = '─'
- else:
- grid[3 * target + 1] = '┌'
- run = '─'
- for col in range(3 * lo + 2, 3 * hi + 1):
- grid[col] = run
- grid[3 * lane + 1] = 'M' if len(parents_of[node]) > 1 else 'o'
- glyphs.append(''.join(grid).rstrip())
- return glyphs
- # because tab character is broken in urwid texts
- def indent(num):
- return " " * (8 - len(str(num)))
- class ListItem(u.WidgetWrap):
-
- def __init__ (self, event):
- g = event['glyph']
- self.content = event
- layer_num = int(event["layer"])
- layer = "layer " + str(layer_num) + indent(layer_num) if layer_num != 0 else "genesis "
- dt = event['hash'][:10] + " │ " + format_timestamp(event['timestamp'])
- t = u.AttrMap(u.Text([('word', dt),
- ('layer-num', " " + layer),
- ('word', g),
- ('cont', ' ' + event['content'])], wrap="ellipsis"),
- {'word':'datetime', 'layer-num': 'reporter', 'cont': 'content'},
- {'word':'event_selected', 'layer-num': 'event_selected', 'cont': 'event_selected'})
- u.WidgetWrap.__init__(self, t)
- def selectable (self):
- return True
-
- def keypress(self, size, key):
- return key
- # Not event header
- class HeaderItem(u.WidgetWrap):
- def __init__ (self, info):
- dt = format_timestamp(info['timestamp'])
- title = f" DAG genesis {info['hash'][:10]} @ {dt}, {info['count']} events "
- t = u.AttrMap(u.Text('──' + title + '─' * max(4, 60 - len(title))), 'header')
- u.WidgetWrap.__init__(self, t)
- def selectable(self):
- return False
- class ListView(u.WidgetWrap):
- def __init__(self):
- u.register_signal(self.__class__, ['show_details'])
- self.walker = u.SimpleFocusListWalker([])
- lb = u.ListBox(self.walker)
- u.WidgetWrap.__init__(self, lb)
- def modified(self):
- focus_w, _ = self.walker.get_focus()
- if focus_w is not None:
- u.emit_signal(self, 'show_details', focus_w.content)
- else:
- print("DAG is empty!")
- exit(-1)
- def set_data(self, items):
- events_widgets = [HeaderItem(i) if i.get('type') == 'header' else ListItem(i) for i in items]
- u.disconnect_signal(self.walker, 'modified', self.modified)
- while len(self.walker) > 0:
- self.walker.pop()
-
- self.walker.extend(events_widgets)
- u.connect_signal(self.walker, "modified", self.modified)
- for idx, widget in enumerate(events_widgets):
- if widget.selectable():
- self.walker.set_focus(idx)
- break
- class DetailView(u.WidgetWrap):
-
- def __init__ (self):
- t = u.Text("")
- u.WidgetWrap.__init__(self, t)
-
- def set_event(self, c):
- s = f'Hash: {c["hash"]}\nChildren: {c["children"]}\nParents: {c["parents"]}\nContent: {c["content"]}\nLayer: {c["layer"]}\nTimestamp: {c["timestamp"]}'
- self._w.set_text(s)
- class App(object):
-
- def unhandled_input(self, key):
- if key in ('q', 'Q'):
- raise u.ExitMainLoop()
- # if key == 'r':
- # await self.update_data(self.config)
- if key == 'enter':
- self.current_view = self.frame2
- self.loop.widget = self.frame2
- if key in ('b', 'B'):
- self.current_view = self.frame1
- self.loop.widget = self.frame1
- def show_details(self, event):
- self.view_two.set_event(event)
-
- def __init__(self):
- self.view_one = ListView()
- u.connect_signal(self.view_one, 'show_details', self.show_details)
- footer = u.AttrWrap(u.Text(" Q to exit"), "footer")
- col_rows = u.raw_display.Screen().get_cols_rows()
- h = col_rows[0] - 2
- f1 = u.Filler(self.view_one, valign='top', height=h)
- c_list = u.LineBox(f1, title="Events")
- columns = u.Columns([('weight', 100, c_list)])
- frame1 = u.AttrMap(u.Frame(body=columns, footer=footer), 'bg')
- self.frame1 = frame1
- ############
- self.view_two = DetailView()
- f2 = u.Filler(self.view_two, valign='top')
- c_details = u.LineBox(f2, title="Details")
- footer = u.AttrWrap(u.Text(" Q to exit, B to main view"), "footer")
- columns = u.Columns([('weight', 100, c_details)])
- frame2 = u.AttrMap(u.Frame(body=columns, footer=footer), 'bg')
- self.frame2 = frame2
- ##########
- self.current_view = self.frame1 # Start with View One
- self.palette = {
- ("bg", "white", "black"),
- ("event", "white", "black"),
- ("event_selected", "white", "light green"),
- ('datetime', "light blue", "black"),
- ('reporter', "dark green", "black"),
- ('content', "", "black"),
- ("header", "dark cyan", "black"),
- ("footer", "white, bold", "dark red")
- }
-
- async def update_data(self, config, replay_mode):
- self.config = config
- dag_dict = await recreate_dag(config, replay_mode)
- if not dag_dict:
- print("DAG is empty!")
- exit(-1)
- parent_child_pairs = []
- for node, details in dag_dict.items():
- for parent in details['parents']:
- if parent == NULL_HASH:
- continue
- parent_child_pairs.append((parent, node))
- # Create a directed graph (genesis events are added explicitly so
- # they are never dropped even when nothing references them)
- dag = nx.DiGraph()
- dag.add_nodes_from(dag_dict.keys())
- dag.add_edges_from(parent_child_pairs)
- # Partition events into one section per DAG
- genesis_nodes = sorted(
- (n for n, details in dag_dict.items() if int(details['layer']) == 0),
- key=lambda n: int(dag_dict[n]['timestamp']), reverse=True)
- sections = []
- grouped = set()
- for genesis in genesis_nodes:
- members = [genesis] + sorted(
- n for n in nx.descendants(dag, genesis) if n not in grouped)
- grouped.update(members)
- sections.append((genesis, members))
- # defensive: events unreachable from any known genesis
- leftovers = [n for n in dag_dict if n not in grouped]
- if leftovers:
- root = min(leftovers, key=lambda n: int(dag_dict[n]['layer']))
- sections.append((root, leftovers))
- items = []
- for root, members in sections:
- sub = dag.subgraph(members)
- order = list(reversed(list(nx.topological_sort(sub))))
- l = []
- for node in order:
- event_details = dag_dict[node] # details
- layer = int(event_details['layer'])
- content = event_details['content'] # event content
- timestamp = event_details['timestamp']
- children = list(sub.successors(node))
- parents = list(sub.predecessors(node))
- try:
- raw = base58.b58decode(content)
- except ValueError:
- raw = content.encode('utf-8', 'replace')
- matches = ''.join(ch if ch.isprintable() else ' ' for ch in raw.decode('utf-8', 'replace')).strip()
- l.append({"layer":f"{layer}", "hash":f"{node}", "children":children, "parents":parents, "content":f"{matches}", "timestamp": f"{timestamp}"})
- glyphs = render_graph_glyphs(l)
- for event, glyph in zip(l, glyphs):
- event['glyph'] = glyph
- items.append({"type":"header", "hash":root, "timestamp":dag_dict[root]['timestamp'], "count":len(order)})
- items.extend(l)
- self.view_one.set_data(items)
- async def start(self, config, replay_mode):
- await self.update_data(config, replay_mode)
- self.loop = u.MainLoop(self.current_view, self.palette, unhandled_input=self.unhandled_input)
- self.loop.run()
- async def recreate_dag(config, replay_mode):
- host = config['host']
- port = config['port']
- rpc = src.rpc.JsonRpc()
- while True:
- try:
- await rpc.start(host, port)
- break
- except OSError:
- print(f"Error: Connection Refused to '{host}:{port}', Either because the daemon is down, is currently syncing or wrong url.")
- sys.exit(-1)
- await rpc.deg_switch(True)
- await rpc.deg_switch(False)
- if replay_mode:
- json_result = await rpc._make_request("eventgraph.replay", [])
- else:
- json_result = await rpc._make_request("eventgraph.get_info", [])
- if json_result['result']['eventgraph_info']:
- return json_result['result']['eventgraph_info']['dag']
-
- async def main(argv):
- val = str('127.0.0.1:9605')
- for i in range(1, len(sys.argv)):
- if sys.argv[i] == "-e":
- try:
- val = sys.argv[i+1]
- except IndexError:
- print("Please provide a value for \'-e\'")
- exit(-1)
- break
- config = {}
- try:
- config['host'], config['port'] = val.split(':')
- except ValueError:
- print("Please provide a port as in: 127.0.0.1:9605")
- exit(-1)
- replay_mode = False
- if len(argv) > 1:
- if '-r' in argv:
- replay_mode = True
- if set(argv) & set(['darkirc', 'irc']):
- config['port'] = 9605
- elif set(argv) & set(['taud', 'tau']):
- config['port'] = 9805
- app = App()
- await app.start(config, replay_mode)
-
- asyncio.run(main(sys.argv))
|