sysgraph 0.0.12__tar.gz → 0.0.14__tar.gz

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.
Files changed (31) hide show
  1. {sysgraph-0.0.12/src/sysgraph.egg-info → sysgraph-0.0.14}/PKG-INFO +1 -1
  2. sysgraph-0.0.14/src/sysgraph/__init__.py +1 -0
  3. {sysgraph-0.0.12 → sysgraph-0.0.14}/src/sysgraph/app.py +3 -26
  4. sysgraph-0.0.14/src/sysgraph/constants.py +36 -0
  5. {sysgraph-0.0.12 → sysgraph-0.0.14}/src/sysgraph/discovery.py +176 -171
  6. sysgraph-0.0.14/src/sysgraph/dist/assets/index-Ba1ztXgb.css +1 -0
  7. sysgraph-0.0.14/src/sysgraph/dist/assets/index-DbxwxpDd.js +735 -0
  8. {sysgraph-0.0.12 → sysgraph-0.0.14}/src/sysgraph/dist/index.html +6 -4
  9. sysgraph-0.0.14/src/sysgraph/graph.py +90 -0
  10. {sysgraph-0.0.12 → sysgraph-0.0.14}/src/sysgraph/main.py +4 -13
  11. sysgraph-0.0.14/src/sysgraph/model.py +104 -0
  12. {sysgraph-0.0.12 → sysgraph-0.0.14/src/sysgraph.egg-info}/PKG-INFO +1 -1
  13. {sysgraph-0.0.12 → sysgraph-0.0.14}/src/sysgraph.egg-info/SOURCES.txt +3 -2
  14. sysgraph-0.0.12/src/sysgraph/__init__.py +0 -1
  15. sysgraph-0.0.12/src/sysgraph/dist/assets/index-B0cm-qFh.css +0 -1
  16. sysgraph-0.0.12/src/sysgraph/dist/assets/index-BaDXONHw.js +0 -735
  17. sysgraph-0.0.12/src/sysgraph/graph.py +0 -104
  18. sysgraph-0.0.12/src/sysgraph/model.py +0 -114
  19. {sysgraph-0.0.12 → sysgraph-0.0.14}/LICENSE +0 -0
  20. {sysgraph-0.0.12 → sysgraph-0.0.14}/MANIFEST.in +0 -0
  21. {sysgraph-0.0.12 → sysgraph-0.0.14}/README.md +0 -0
  22. {sysgraph-0.0.12 → sysgraph-0.0.14}/pyproject.toml +0 -0
  23. {sysgraph-0.0.12 → sysgraph-0.0.14}/setup.cfg +0 -0
  24. {sysgraph-0.0.12 → sysgraph-0.0.14}/setup.py +0 -0
  25. {sysgraph-0.0.12 → sysgraph-0.0.14}/src/sysgraph/__main__.py +0 -0
  26. {sysgraph-0.0.12 → sysgraph-0.0.14}/src/sysgraph/dist/assets/icon-TKtfQOgj.png +0 -0
  27. {sysgraph-0.0.12 → sysgraph-0.0.14}/src/sysgraph.egg-info/dependency_links.txt +0 -0
  28. {sysgraph-0.0.12 → sysgraph-0.0.14}/src/sysgraph.egg-info/entry_points.txt +0 -0
  29. {sysgraph-0.0.12 → sysgraph-0.0.14}/src/sysgraph.egg-info/not-zip-safe +0 -0
  30. {sysgraph-0.0.12 → sysgraph-0.0.14}/src/sysgraph.egg-info/requires.txt +0 -0
  31. {sysgraph-0.0.12 → sysgraph-0.0.14}/src/sysgraph.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sysgraph
3
- Version: 0.0.12
3
+ Version: 0.0.14
4
4
  Summary: Visualizer for processes and their interconnections
5
5
  Home-page: https://github.com/gubenkoved/sysgraph
6
6
  Author: Eugene Gubenkov
@@ -0,0 +1 @@
1
+ __version__ = "0.0.14"
@@ -71,39 +71,16 @@ def health():
71
71
 
72
72
 
73
73
  @app.get("/api/graph", response_model=GraphSchema)
74
- def get_graph() -> GraphSchema:
74
+ def get_graph() -> dict:
75
75
  graph = build_graph()
76
- graph_dict = graph.as_dict()
77
-
78
- LOGGER.debug(graph_dict)
79
-
80
- return GraphSchema(
81
- nodes=[
82
- GraphNodeSchema(
83
- id=node["id"], type=node["type"], properties=node["properties"]
84
- )
85
- for node in graph_dict["nodes"]
86
- ],
87
- edges=[
88
- GraphEdgeSchema(
89
- id=edge["id"],
90
- source_id=edge["source_id"],
91
- target_id=edge["target_id"],
92
- type=edge["type"],
93
- properties=edge["properties"],
94
- )
95
- for edge in graph_dict["edges"]
96
- ],
97
- )
76
+ return graph.as_dict()
98
77
 
99
78
 
100
79
  # Serve built assets (JS/CSS bundles, Shoelace icons, etc.) from the same
101
80
  # directory that provides index.html. This catch-all mount MUST come after
102
81
  # all explicit routes so that /api/* and / are matched first.
103
82
  if _dist_dir.is_dir():
104
- app.mount(
105
- "/", StaticFiles(directory=str(_dist_dir)), name="static"
106
- )
83
+ app.mount("/", StaticFiles(directory=str(_dist_dir)), name="static")
107
84
 
108
85
 
109
86
  def main():
@@ -0,0 +1,36 @@
1
+ # -- Node types --
2
+ NODE_PROCESS = "process"
3
+ NODE_PIPE = "pipe"
4
+ NODE_SOCKET = "socket"
5
+ NODE_UDS = "uds"
6
+ NODE_EXTERNAL_IP = "external_ip"
7
+
8
+ # -- Edge types --
9
+ EDGE_CHILD_PROCESS = "child_process"
10
+ EDGE_UDS = "uds"
11
+ EDGE_UDS_CONNECTION = "uds_connection"
12
+ EDGE_PIPE = "pipe"
13
+ EDGE_SOCKET = "socket"
14
+ EDGE_SOCKET_CONNECTION = "socket_connection"
15
+ EDGE_EXTERNAL_SOCKET = "external_socket"
16
+
17
+
18
+ # -- Node ID helpers --
19
+ def process_node_id(pid: int) -> str:
20
+ return f"{NODE_PROCESS}::{pid}"
21
+
22
+
23
+ def uds_node_id(inode: int) -> str:
24
+ return f"{NODE_UDS}::{inode}"
25
+
26
+
27
+ def pipe_node_id(inode: str) -> str:
28
+ return f"{NODE_PIPE}::{inode}"
29
+
30
+
31
+ def socket_node_id(address: str, socket_type: str) -> str:
32
+ return f"{NODE_SOCKET}::{address}::{socket_type}"
33
+
34
+
35
+ def external_ip_node_id(ip: str) -> str:
36
+ return f"{NODE_EXTERNAL_IP}::{ip}"
@@ -5,10 +5,28 @@ import subprocess
5
5
  from collections import defaultdict
6
6
  from concurrent.futures import ThreadPoolExecutor
7
7
  from pathlib import Path
8
- from socket import AddressFamily
9
8
 
10
9
  import psutil
11
10
 
11
+ from sysgraph.constants import (
12
+ EDGE_CHILD_PROCESS,
13
+ EDGE_EXTERNAL_SOCKET,
14
+ EDGE_PIPE,
15
+ EDGE_SOCKET,
16
+ EDGE_SOCKET_CONNECTION,
17
+ EDGE_UDS,
18
+ EDGE_UDS_CONNECTION,
19
+ NODE_EXTERNAL_IP,
20
+ NODE_PIPE,
21
+ NODE_PROCESS,
22
+ NODE_SOCKET,
23
+ NODE_UDS,
24
+ external_ip_node_id,
25
+ pipe_node_id,
26
+ process_node_id,
27
+ socket_node_id,
28
+ uds_node_id,
29
+ )
12
30
  from sysgraph.graph import Graph, Node
13
31
  from sysgraph.model import (
14
32
  NetConnection,
@@ -37,6 +55,7 @@ def discover_processes() -> list[Process]:
37
55
  ]
38
56
  for proc in psutil.process_iter(attrs):
39
57
  info = proc.info
58
+
40
59
  p = Process(pid=info["pid"])
41
60
  p.parent_pid = info["ppid"]
42
61
  p.user = info["username"]
@@ -48,6 +67,12 @@ def discover_processes() -> list[Process]:
48
67
  p.cpu_user = cpu_times.user
49
68
  p.cpu_system = cpu_times.system
50
69
 
70
+ mem_info = proc.memory_info()
71
+ if mem_info:
72
+ p.memory_rss = mem_info.rss
73
+ p.memory_vms = mem_info.vms
74
+ p.memory_shared = mem_info.shared
75
+
51
76
  p.environment = info.get("environ")
52
77
 
53
78
  processes.append(p)
@@ -200,26 +225,6 @@ def get_processes_open_files() -> dict[int, list[ProcessOpenFile]]:
200
225
  return result_map
201
226
 
202
227
 
203
- def get_net_connections(pid: int) -> list[NetConnection]:
204
- proc = psutil.Process(pid)
205
- connections: list[NetConnection] = []
206
- for pcon in proc.net_connections(kind="all"):
207
- if pcon.family not in (AddressFamily.AF_INET, AddressFamily.AF_INET6):
208
- continue
209
- connections.append(
210
- NetConnection(
211
- pid=pid,
212
- local_address=SocketAddress(pcon.laddr.ip, pcon.laddr.port),
213
- remote_address=SocketAddress(pcon.raddr.ip, pcon.raddr.port)
214
- if pcon.raddr
215
- else None,
216
- socket_type=pcon.type.name,
217
- state=pcon.status,
218
- )
219
- )
220
- return connections
221
-
222
-
223
228
  def get_all_net_connections() -> dict[int, list[NetConnection]]:
224
229
  """Fetch all network connections system-wide in a single call."""
225
230
  result: dict[int, list[NetConnection]] = defaultdict(list)
@@ -240,28 +245,17 @@ def get_all_net_connections() -> dict[int, list[NetConnection]]:
240
245
  return result
241
246
 
242
247
 
243
- def build_graph(discover_uds_connectivity: bool = True) -> Graph:
244
- graph = Graph()
245
-
248
+ def _add_process_nodes(
249
+ graph: Graph,
250
+ processes: list[Process],
251
+ ) -> dict[int, Node]:
252
+ """Create process nodes and parent-child edges."""
246
253
  pid_to_node: dict[int, Node] = {}
247
254
 
248
- # run independent discovery steps in parallel (all I/O-bound)
249
- with ThreadPoolExecutor() as executor:
250
- processes_future = executor.submit(discover_processes)
251
- uds_future = executor.submit(discover_unix_sockets)
252
- open_files_future = executor.submit(get_processes_open_files)
253
- net_connections_future = executor.submit(get_all_net_connections)
254
-
255
- processes = processes_future.result()
256
- uds = uds_future.result()
257
- open_files_map = open_files_future.result()
258
- all_net_connections = net_connections_future.result()
259
-
260
255
  for proc in processes:
261
- proc_node_id = f"process::{proc.pid}"
262
256
  node = graph.add_node(
263
- proc_node_id,
264
- "process",
257
+ process_node_id(proc.pid),
258
+ NODE_PROCESS,
265
259
  properties={
266
260
  "pid": proc.pid,
267
261
  "command": proc.command,
@@ -269,48 +263,50 @@ def build_graph(discover_uds_connectivity: bool = True) -> Graph:
269
263
  "name": proc.name,
270
264
  "cpu_user": proc.cpu_user,
271
265
  "cpu_system": proc.cpu_system,
266
+ "memory_rss": proc.memory_rss,
267
+ "memory_vms": proc.memory_vms,
268
+ "memory_shared": proc.memory_shared,
272
269
  "environment": proc.environment,
273
270
  },
274
271
  )
275
272
  pid_to_node[proc.pid] = node
276
273
 
277
- # add parent-child relationships
278
274
  for proc in processes:
279
- proc_node = pid_to_node[proc.pid]
280
- parent_proc_node = None
281
- if proc.parent_pid is not None:
282
- parent_proc_node = pid_to_node.get(proc.parent_pid)
283
- if parent_proc_node is not None:
284
- _ = graph.add_edge(
285
- source_id=parent_proc_node.id,
286
- target_id=proc_node.id,
287
- rel_type="child_process",
275
+ if proc.parent_pid is None:
276
+ continue
277
+ parent_node = pid_to_node.get(proc.parent_pid)
278
+ if parent_node is not None:
279
+ graph.add_edge(
280
+ source_id=parent_node.id,
281
+ target_id=pid_to_node[proc.pid].id,
282
+ rel_type=EDGE_CHILD_PROCESS,
288
283
  )
289
284
 
285
+ return pid_to_node
286
+
287
+
288
+ def _add_uds_nodes(
289
+ graph: Graph,
290
+ uds_sockets: list[UnixDomainSocket],
291
+ pid_to_node: dict[int, Node],
292
+ discover_connectivity: bool,
293
+ ) -> None:
294
+ """Create UDS nodes, process→UDS edges, and UDS connection edges."""
290
295
  uds_node_map: dict[int, Node] = {}
291
296
 
292
297
  def ensure_uds_node(uds_socket: UnixDomainSocket) -> Node:
293
298
  inode = uds_socket.local_inode
294
-
295
299
  if inode in uds_node_map:
296
300
  return uds_node_map[inode]
297
301
 
298
- node_id = f"uds::{inode}"
299
-
300
- if uds_socket.local_path:
301
- if uds_socket.local_path == "*":
302
- label = f"uds:[{inode}]"
303
- else:
304
- label = uds_socket.local_path
305
-
306
- # strip trailing @ if present (abstract unix socket)
307
- label = label.rstrip("@")
308
- else: # no local path
302
+ if uds_socket.local_path and uds_socket.local_path != "*":
303
+ label = uds_socket.local_path.rstrip("@")
304
+ else:
309
305
  label = f"uds:[{inode}]"
310
306
 
311
307
  node = graph.add_node(
312
- node_id,
313
- "uds",
308
+ uds_node_id(inode),
309
+ NODE_UDS,
314
310
  properties={
315
311
  "label": label,
316
312
  "local_inode": uds_socket.local_inode,
@@ -324,12 +320,9 @@ def build_graph(discover_uds_connectivity: bool = True) -> Graph:
324
320
  uds_node_map[inode] = node
325
321
  return node
326
322
 
327
- # track which process→uds edges have been added to avoid duplicates
328
323
  uds_process_edges: set[tuple[int, int]] = set()
329
324
 
330
- # create UDS nodes for ALL discovered sockets and connect them
331
- # to their processes
332
- for uds_socket in uds:
325
+ for uds_socket in uds_sockets:
333
326
  uds_node = ensure_uds_node(uds_socket)
334
327
  for p_ref in uds_socket.processes:
335
328
  edge_key = (p_ref.pid, uds_socket.local_inode)
@@ -338,125 +331,129 @@ def build_graph(discover_uds_connectivity: bool = True) -> Graph:
338
331
  graph.add_edge(
339
332
  source_id=pid_to_node[p_ref.pid].id,
340
333
  target_id=uds_node.id,
341
- rel_type="uds",
334
+ rel_type=EDGE_UDS,
342
335
  properties={
343
336
  "label": f"uds (fd={p_ref.fd})",
344
337
  "fd": p_ref.fd,
345
338
  },
346
339
  )
347
340
 
348
- # optionally discover connected UDS pairs and add connection edges
349
- if discover_uds_connectivity:
350
- for con in discover_connected_uds(uds):
341
+ if discover_connectivity:
342
+ for con in discover_connected_uds(uds_sockets):
351
343
  uds_node1 = ensure_uds_node(con.socket1)
352
344
  uds_node2 = ensure_uds_node(con.socket2)
353
-
354
345
  graph.add_edge(
355
346
  uds_node1.id,
356
347
  uds_node2.id,
357
- "uds_connection",
348
+ EDGE_UDS_CONNECTION,
358
349
  properties={
359
350
  "directional": False,
360
351
  "dashed": True,
361
352
  },
362
353
  )
363
354
 
364
- pipe_node_to_node = {}
365
355
 
366
- def ensure_pipe_node(file_node):
367
- if file_node not in pipe_node_to_node:
368
- pipe_node_to_node[file_node] = graph.add_node(
369
- f"pipe::{file_node}",
370
- "pipe",
371
- properties={
372
- "label": f"pipe:[{file_node}]",
373
- },
356
+ def _add_pipe_nodes(
357
+ graph: Graph,
358
+ open_files_map: dict[int, list[ProcessOpenFile]],
359
+ pid_to_node: dict[int, Node],
360
+ ) -> None:
361
+ """Create pipe nodes and directional pipe edges."""
362
+ pipe_nodes: dict[str, Node] = {}
363
+
364
+ def ensure_pipe_node(file_node: str) -> Node:
365
+ if file_node not in pipe_nodes:
366
+ pipe_nodes[file_node] = graph.add_node(
367
+ pipe_node_id(file_node),
368
+ NODE_PIPE,
369
+ properties={"label": f"pipe:[{file_node}]"},
374
370
  )
375
- return pipe_node_to_node[file_node]
371
+ return pipe_nodes[file_node]
376
372
 
377
373
  for pid, files in open_files_map.items():
378
374
  if pid not in pid_to_node:
379
375
  continue
380
376
  process_node = pid_to_node[pid]
381
377
  for file in files:
382
- # so far just show the pipes
383
378
  if file.file_type != "FIFO":
384
379
  continue
385
380
  node = ensure_pipe_node(file.node)
386
-
381
+ props = {
382
+ "label": f"pipe (fd={file.fd})",
383
+ "fd": file.fd,
384
+ "mode": file.mode,
385
+ }
387
386
  if file.mode == "r":
388
- _ = graph.add_edge(
387
+ graph.add_edge(
389
388
  source_id=node.id,
390
389
  target_id=process_node.id,
391
- rel_type="pipe",
392
- properties={
393
- "label": f"pipe (fd={file.fd})",
394
- "fd": file.fd,
395
- "mode": file.mode,
396
- },
390
+ rel_type=EDGE_PIPE,
391
+ properties=props,
397
392
  )
398
393
  else:
399
- _ = graph.add_edge(
394
+ graph.add_edge(
400
395
  source_id=process_node.id,
401
396
  target_id=node.id,
402
- rel_type="pipe",
403
- properties={
404
- "label": f"pipe (fd={file.fd})",
405
- "fd": file.fd,
406
- "mode": file.mode,
407
- },
397
+ rel_type=EDGE_PIPE,
398
+ properties=props,
408
399
  )
409
400
 
410
- socket_to_pids: dict[tuple[SocketAddress, str], list[int]] = {}
411
- socket_to_node: dict[tuple[SocketAddress, str], Node] = {}
412
401
 
413
- def is_ipv6(address: str) -> bool:
414
- return ":" in address
402
+ _SIMPLE_SOCKET_TYPE = {
403
+ "SOCK_DGRAM": "UDP",
404
+ "SOCK_STREAM": "TCP",
405
+ }
415
406
 
416
- def ensure_socket(address: SocketAddress, socket_type, state: str):
417
- key = (address, socket_type)
418
- if key in socket_to_node:
419
- return socket_to_node[key]
420
- socket_node_id = f"socket::{address}::{socket_type}"
421
- socket_node = graph.add_node(socket_node_id, "socket")
422
407
 
423
- simple_socket_type = {
424
- "SOCK_DGRAM": "UDP",
425
- "SOCK_STREAM": "TCP",
426
- }
408
+ def _add_network_nodes(
409
+ graph: Graph,
410
+ processes: list[Process],
411
+ all_net_connections: dict[int, list[NetConnection]],
412
+ pid_to_node: dict[int, Node],
413
+ ) -> None:
414
+ """Create socket/external-IP nodes and their edges."""
415
+ sock_to_pids: dict[tuple[SocketAddress, str], list[int]] = {}
416
+ sock_to_node: dict[tuple[SocketAddress, str], Node] = {}
417
+ connected_sockets: set[tuple[str, ...]] = set()
427
418
 
428
- simple_type = simple_socket_type.get(socket_type, socket_type)
419
+ def is_ipv6(address: str) -> bool:
420
+ return ":" in address
429
421
 
422
+ def ensure_socket(
423
+ address: SocketAddress, sock_type: str, state: str
424
+ ) -> Node:
425
+ key = (address, sock_type)
426
+ if key in sock_to_node:
427
+ return sock_to_node[key]
428
+
429
+ node = graph.add_node(
430
+ socket_node_id(str(address), sock_type),
431
+ NODE_SOCKET,
432
+ )
433
+ simple = _SIMPLE_SOCKET_TYPE.get(sock_type, sock_type)
430
434
  if is_ipv6(address.ip):
431
- socket_node.properties["label"] = (
432
- f"[{address.ip}]:{address.port} ({simple_type})"
435
+ node.properties["label"] = (
436
+ f"[{address.ip}]:{address.port} ({simple})"
433
437
  )
434
438
  else:
435
- socket_node.properties["label"] = (
436
- f"{address.ip}:{address.port} ({simple_type})"
439
+ node.properties["label"] = (
440
+ f"{address.ip}:{address.port} ({simple})"
437
441
  )
442
+ node.properties["state"] = state
443
+ node.properties["socket_type"] = sock_type
444
+ sock_to_node[key] = node
445
+ return node
438
446
 
439
- socket_node.properties["state"] = state
440
- socket_node.properties["socket_type"] = socket_type
441
-
442
- socket_to_node[key] = socket_node
443
- return socket_node
444
-
445
- connected_sockets = set()
446
-
447
- def ensure_sockets_connected(socket1: Node, socket2: Node):
448
- key = tuple(sorted([socket1.id, socket2.id]))
447
+ def ensure_sockets_connected(s1: Node, s2: Node) -> None:
448
+ key = tuple(sorted([s1.id, s2.id]))
449
449
  if key in connected_sockets:
450
450
  return
451
451
  connected_sockets.add(key)
452
- _ = graph.add_edge(
453
- socket1.id,
454
- socket2.id,
455
- "socket_connection",
456
- {
457
- "directional": False,
458
- "dashed": True,
459
- },
452
+ graph.add_edge(
453
+ s1.id,
454
+ s2.id,
455
+ EDGE_SOCKET_CONNECTION,
456
+ {"directional": False, "dashed": True},
460
457
  )
461
458
 
462
459
  for proc in processes:
@@ -468,26 +465,25 @@ def build_graph(discover_uds_connectivity: bool = True) -> Graph:
468
465
  continue
469
466
 
470
467
  for net_con in net_connections:
471
- socket_id = (net_con.local_address, net_con.socket_type)
472
-
473
- # ensure socket in graph
468
+ sock_key = (
469
+ net_con.local_address,
470
+ net_con.socket_type,
471
+ )
474
472
  local_socket = ensure_socket(
475
473
  net_con.local_address,
476
474
  net_con.socket_type,
477
475
  net_con.state,
478
476
  )
479
477
 
480
- # process connection
481
- if proc.pid not in socket_to_pids.get(socket_id, []):
482
- socket_to_pids[socket_id] = socket_to_pids.get(
483
- socket_id, []
484
- ) + [proc.pid]
478
+ if proc.pid not in sock_to_pids.get(sock_key, []):
479
+ sock_to_pids[sock_key] = sock_to_pids.get(sock_key, []) + [
480
+ proc.pid
481
+ ]
485
482
  graph.add_edge(
486
483
  source_id=proc_node.id,
487
484
  target_id=local_socket.id,
488
- rel_type="socket",
485
+ rel_type=EDGE_SOCKET,
489
486
  )
490
-
491
487
  if net_con.remote_address:
492
488
  remote_socket = ensure_socket(
493
489
  net_con.remote_address,
@@ -496,35 +492,44 @@ def build_graph(discover_uds_connectivity: bool = True) -> Graph:
496
492
  )
497
493
  ensure_sockets_connected(local_socket, remote_socket)
498
494
 
499
- # post-process all sockets which are NOT connected to any process -- these
500
- # are remote endpoints, group the by IP address
501
- external_ip_to_node = {}
502
-
503
- def ensure_external_ip(address):
504
- if address not in external_ip_to_node:
505
- external_ip_to_node[address] = graph.add_node(
506
- f"external_ip::{address}",
507
- "external_ip",
508
- {
509
- "label": address,
510
- },
495
+ # sockets not connected to any process are remote endpoints
496
+ external_ip_nodes: dict[str, Node] = {}
497
+
498
+ for sock_key, sock_node in sock_to_node.items():
499
+ if sock_to_pids.get(sock_key):
500
+ continue
501
+ ip = sock_key[0].ip
502
+ if ip not in external_ip_nodes:
503
+ external_ip_nodes[ip] = graph.add_node(
504
+ external_ip_node_id(ip),
505
+ NODE_EXTERNAL_IP,
506
+ {"label": ip},
511
507
  )
512
- return external_ip_to_node[address]
508
+ graph.add_edge(
509
+ external_ip_nodes[ip].id,
510
+ sock_node.id,
511
+ EDGE_EXTERNAL_SOCKET,
512
+ )
513
513
 
514
- for socket, socket_node in socket_to_node.items():
515
- pids = socket_to_pids.get(socket)
516
514
 
517
- if pids:
518
- continue
515
+ def build_graph(discover_uds_connectivity: bool = True) -> Graph:
516
+ graph = Graph()
517
+
518
+ # run independent discovery steps in parallel (all I/O-bound)
519
+ with ThreadPoolExecutor() as executor:
520
+ processes_future = executor.submit(discover_processes)
521
+ uds_future = executor.submit(discover_unix_sockets)
522
+ open_files_future = executor.submit(get_processes_open_files)
523
+ net_connections_future = executor.submit(get_all_net_connections)
519
524
 
520
- external_ip = socket[0].ip
521
- external_ip_node = ensure_external_ip(external_ip)
525
+ processes = processes_future.result()
526
+ uds = uds_future.result()
527
+ open_files_map = open_files_future.result()
528
+ all_net_connections = net_connections_future.result()
522
529
 
523
- # add connection
524
- graph.add_edge(
525
- external_ip_node.id,
526
- socket_node.id,
527
- "external_socket",
528
- )
530
+ pid_to_node = _add_process_nodes(graph, processes)
531
+ _add_uds_nodes(graph, uds, pid_to_node, discover_uds_connectivity)
532
+ _add_pipe_nodes(graph, open_files_map, pid_to_node)
533
+ _add_network_nodes(graph, processes, all_net_connections, pid_to_node)
529
534
 
530
535
  return graph
@@ -0,0 +1 @@
1
+ :root{--z-toolbar: 100;--z-search-help: 150;--z-context-menu: 200;--z-details-panel: 50;--z-selection-overlay: 10;--z-version-badge: 10;--bg-toolbar: rgba(255, 255, 255, .95);--bg-panel: rgba(255, 255, 255, .97);--border-subtle: rgba(0, 0, 0, .08);--border-light: rgba(0, 0, 0, .1);--border-medium: rgba(0, 0, 0, .12);--shadow-xs: rgba(0, 0, 0, .08);--shadow-sm: rgba(0, 0, 0, .14);--shadow-md: rgba(0, 0, 0, .15);--accent-primary: #1a56db;--accent-info: #1a73e8;--accent-danger: #dc2626;--toolbar-height: 48px;--spacing-xs: 4px;--spacing-sm: 6px;--spacing-md: 8px;--spacing-lg: 16px;--font-family: "Ubuntu", "Segoe UI", "Arial", sans-serif;--font-size-xs: 11px;--font-size-sm: 12px;--font-size-md: 13px;--radius-sm: 4px;--radius-md: 8px;--radius-lg: 12px;--button-height: 28px;--icon-button-size: 24px;--tp-base-background-color: hsla(230, 5%, 90%, 1);--tp-base-shadow-color: hsla(0, 0%, 0%, .1);--tp-button-background-color: hsla(230, 7%, 75%, 1);--tp-button-background-color-active: hsla(230, 7%, 60%, 1);--tp-button-background-color-focus: hsla(230, 7%, 65%, 1);--tp-button-background-color-hover: hsla(230, 7%, 70%, 1);--tp-button-foreground-color: hsla(230, 10%, 30%, 1);--tp-container-background-color: hsla(230, 15%, 30%, .2);--tp-container-background-color-active: hsla(230, 15%, 30%, .32);--tp-container-background-color-focus: hsla(230, 15%, 30%, .28);--tp-container-background-color-hover: hsla(230, 15%, 30%, .24);--tp-container-foreground-color: hsla(230, 10%, 30%, 1);--tp-groove-foreground-color: hsla(230, 15%, 30%, .1);--tp-input-background-color: hsla(230, 15%, 30%, .1);--tp-input-background-color-active: hsla(230, 15%, 30%, .22);--tp-input-background-color-focus: hsla(230, 15%, 30%, .18);--tp-input-background-color-hover: hsla(230, 15%, 30%, .14);--tp-input-foreground-color: hsla(230, 10%, 30%, 1);--tp-label-foreground-color: hsla(230, 10%, 30%, .7);--tp-monitor-background-color: hsla(230, 15%, 30%, .1);--tp-monitor-foreground-color: hsla(230, 10%, 30%, .5)}html,body{height:100%;margin:0;overflow:hidden;font-family:var(--font-family)}#toolbar{position:fixed;top:0;left:0;right:0;height:var(--toolbar-height);z-index:var(--z-toolbar);background:var(--bg-toolbar);border-bottom:1px solid var(--border-light);display:flex;align-items:center;padding:0 var(--spacing-lg);gap:var(--spacing-sm);box-shadow:0 1px 3px var(--shadow-xs)}#toolbar md-outlined-button{--md-outlined-button-container-height: var(--button-height);--md-outlined-button-label-text-size: var(--font-size-sm);--md-outlined-button-label-text-weight: 500;--md-outlined-button-icon-size: 16px;--md-outlined-button-leading-space: 10px;--md-outlined-button-trailing-space: 12px;--md-outlined-button-icon-offset: -2px;--md-outlined-button-container-shape: var(--radius-sm)}md-outlined-button.active{--md-outlined-button-container-color: #d0e1ff;--md-outlined-button-label-text-color: var(--accent-primary);--md-outlined-button-outline-color: var(--accent-primary);--md-outlined-button-icon-color: var(--accent-primary)}md-outlined-button.danger{--md-outlined-button-label-text-color: var(--accent-danger);--md-outlined-button-outline-color: var(--accent-danger);--md-outlined-button-icon-color: var(--accent-danger)}.toolbar-separator{width:1px;height:var(--icon-button-size);background:var(--border-light)}#searchInput{width:240px;height:var(--button-height);--md-outlined-text-field-container-shape: var(--radius-sm);--md-outlined-text-field-top-space: var(--spacing-xs);--md-outlined-text-field-bottom-space: var(--spacing-xs);--md-outlined-text-field-input-text-size: var(--font-size-sm);--md-outlined-text-field-label-text-size: var(--font-size-sm)}#searchHelpTrigger{--md-icon-button-icon-size: 18px;--md-icon-button-state-layer-height: var(--button-height);--md-icon-button-state-layer-width: var(--button-height);height:var(--button-height);width:var(--button-height)}.search-help-anchor{position:relative;display:inline-flex}#searchHelp{display:none;position:absolute;top:100%;left:0;margin-top:var(--spacing-xs);z-index:var(--z-search-help);border:1px solid var(--border-medium);border-radius:var(--radius-md);padding:10px 14px;box-shadow:0 4px 12px var(--shadow-md);font-size:var(--font-size-sm);line-height:1.5;background:#1e293b;color:#fff}#searchHelp.open{display:block}#searchHelp code{background:#ffffff1f;padding:1px var(--spacing-xs);border-radius:3px}#content{position:fixed;top:var(--toolbar-height);left:0;right:0;bottom:0}#graph{width:100%;height:100%;position:relative}.details-panel{position:absolute;top:var(--spacing-md);left:var(--spacing-md);width:420px;height:350px;min-width:200px;min-height:120px;max-height:calc(100% - var(--spacing-lg));z-index:var(--z-details-panel);background:var(--bg-panel);box-shadow:0 2px 12px var(--shadow-sm);border:1px solid var(--border-subtle);border-radius:var(--radius-lg);display:none;flex-direction:column;overflow:hidden;resize:both}.details-panel.open{display:flex}.details-panel .panel-header{display:flex;align-items:center;height:var(--button-height);padding:0 2px 0 10px;border-bottom:1px solid var(--border-subtle);flex-shrink:0;cursor:grab;-webkit-user-select:none;user-select:none}.details-panel .panel-header:active{cursor:grabbing}.details-panel .panel-title{font-size:var(--font-size-sm);font-weight:500;color:#333;flex:1}.details-panel md-icon-button{--md-icon-button-icon-size: 16px;--md-icon-button-state-layer-height: var(--icon-button-size);--md-icon-button-state-layer-width: var(--icon-button-size);height:var(--icon-button-size);width:var(--icon-button-size)}.details-panel .panel-body{flex:1;overflow-y:auto;padding:var(--spacing-md);font-family:var(--font-family)}#detailsPanel{border-left:3px solid var(--accent-info)}#detailsPanel .panel-header{background:#1a73e80f}#contextMenu{display:none;position:fixed;z-index:var(--z-context-menu);background:var(--bg-panel);border:1px solid var(--border-medium);border-radius:var(--spacing-sm);padding:var(--spacing-xs) 0;box-shadow:0 4px 12px var(--shadow-md);min-width:160px;font-size:var(--font-size-md)}.context-menu-item{padding:var(--spacing-sm) 14px;cursor:pointer;-webkit-user-select:none;user-select:none}.context-menu-item:hover{background:#0000000f}.context-menu-divider{height:1px;margin:var(--spacing-xs) 0;background:var(--border-medium)}.tp-rotv{font-family:var(--font-family)!important;opacity:.9}#settingsPane{max-height:calc(100% - 20px);position:absolute;overflow-y:auto;right:10px;top:10px}#version-badge{position:fixed;bottom:var(--spacing-sm);left:var(--spacing-md);font-size:var(--font-size-xs);color:#0000004d;pointer-events:none;z-index:var(--z-version-badge);font-family:var(--font-family)}.muted{color:#666}hr{border:none;border-top:1px solid var(--border-subtle);margin:var(--spacing-sm) 0}