Attach normalized binary metadata to SymGraph pushes

This commit is contained in:
Jason Tang
2026-04-05 09:25:36 -04:00
parent 8d249f9226
commit 6fa331c3b4
5 changed files with 580 additions and 151 deletions
+175 -12
View File
@@ -8,13 +8,15 @@ querying, pushing, and pulling symbols and graph data.
import asyncio
import json
import os
import webbrowser
from typing import Dict, List, Optional, Any, Callable
from typing import Dict, List, Optional, Any, Callable, Tuple
from ..qt_compat import QMessageBox, QThread, Signal, QObject
from src.services.analysis_db_service import analysis_db_service
from src.services.graphrag.graph_store import GraphStore
from src.services.graphrag.models import GraphNode as LocalGraphNode, GraphEdge as LocalGraphEdge, NodeType, EdgeType
from src.services.settings_service import settings_service
from src.services.symgraph_service import (
symgraph_service, SymGraphServiceError, SymGraphAuthError,
@@ -31,6 +33,7 @@ from src.ida_compat import log, get_binary_hash, execute_on_main_thread
# IDA imports
try:
import idaapi
import ida_ida
import idautils
import ida_funcs
import ida_name
@@ -108,7 +111,8 @@ class PushWorker(QThread):
documents: Optional[List[Dict]] = None,
graph_data: Optional[Dict] = None,
visibility: str = "public",
fingerprints: Optional[List[Dict[str, str]]] = None
fingerprints: Optional[List[Dict[str, str]]] = None,
binary_metadata: Optional[Dict[str, Any]] = None
):
super().__init__()
self.sha256 = sha256
@@ -117,6 +121,7 @@ class PushWorker(QThread):
self.graph_data = graph_data
self.visibility = visibility
self.fingerprints = fingerprints or [] # List of {'type': str, 'value': str}
self.binary_metadata = dict(binary_metadata or {})
def run(self):
try:
@@ -135,6 +140,14 @@ class PushWorker(QThread):
)
total_result.binary_revision = target_revision
if self.binary_metadata:
loop.run_until_complete(
symgraph_service.update_binary_metadata(
self.sha256,
self.binary_metadata
)
)
# Push symbols in chunks if provided
if self.symbols:
result = loop.run_until_complete(
@@ -686,8 +699,11 @@ class SymGraphController(QObject):
self._pending_push_documents: List[Dict[str, Any]] = []
self._pending_apply_documents: List[Dict[str, Any]] = []
self._pending_apply_summary: Dict[str, int] = {}
self._last_binary_sha: Optional[str] = None
self._active_query_sha: Optional[str] = None
# Connect view signals
self.view.set_auto_refresh_enabled(settings_service.is_symgraph_auto_refresh_enabled())
self._connect_signals()
# Update binary info if available
@@ -696,6 +712,7 @@ class SymGraphController(QObject):
def _connect_signals(self):
"""Connect view signals to controller methods."""
self.view.query_requested.connect(self.handle_query)
self.view.auto_refresh_changed.connect(self._set_auto_refresh_enabled)
self.view.open_binary_requested.connect(self.handle_open_binary)
self.view.push_preview_requested.connect(self.handle_push_preview)
self.view.push_execute_requested.connect(self.handle_push)
@@ -707,11 +724,19 @@ class SymGraphController(QObject):
"""Initialize binary context (called when binary is loaded in IDA)"""
self._update_binary_info()
def _is_auto_refresh_enabled(self) -> bool:
return settings_service.is_symgraph_auto_refresh_enabled()
def _set_auto_refresh_enabled(self, enabled: bool):
settings_service.set_symgraph_auto_refresh_enabled(enabled)
def _update_binary_info(self):
"""Update binary info display from current IDA database."""
previous_sha = self._last_binary_sha
if _IN_IDA:
try:
name = ida_nalt.get_root_filename() or "Unknown"
binary_metadata = self._get_original_binary_metadata()
name = binary_metadata.get('file_name') or ida_nalt.get_root_filename() or "Unknown"
sha256 = self._get_sha256()
local_metadata: Dict[str, Any] = {}
try:
@@ -732,11 +757,26 @@ class SymGraphController(QObject):
except Exception:
pass
self.view.set_binary_info(name, sha256, local_metadata=local_metadata or None)
if sha256 != previous_sha:
self._last_binary_sha = sha256
self.view.hide_stats()
self.view.reset_query_status()
self.view.set_open_binary_url(None)
self.view.clear_conflicts()
self.view.clear_push_preview()
if sha256 and self._is_auto_refresh_enabled():
self.handle_query()
except Exception as e:
log.log_error(f"Error getting binary info: {e}")
self.view.set_binary_info("<error>", None, local_metadata=None)
else:
self._last_binary_sha = None
self.view.set_binary_info("<no binary loaded>", None, local_metadata=None)
self.view.hide_stats()
self.view.reset_query_status()
self.view.set_open_binary_url(None)
self.view.clear_conflicts()
self.view.clear_push_preview()
def _get_symbol_provenance(self, is_auto: bool, address: int, symbol_type: str) -> str:
"""Determine symbol provenance: decompiler, llm, or user."""
@@ -753,6 +793,114 @@ class SymGraphController(QObject):
pass
return 'user'
@staticmethod
def _basename_only(path: Optional[str]) -> Optional[str]:
if not path:
return None
return path.split('/')[-1].split('\\')[-1] or None
@staticmethod
def _is_analysis_container_name(filename: Optional[str]) -> bool:
if not filename:
return False
lower_name = filename.lower()
return lower_name.endswith('.i64') or lower_name.endswith('.idb')
@staticmethod
def _normalize_architecture(raw_value: Optional[str], bitness: Optional[int]) -> Optional[str]:
if not raw_value:
return None
value = str(raw_value).strip().lower()
if value == "metapc":
return "x86_64" if bitness == 64 else "x86"
if value in {"x86", "i386", "i486", "i586", "i686", "80386"}:
return "x86"
if value in {"x86_64", "amd64", "x64"}:
return "x86_64"
if value in {"arm64", "aarch64"}:
return "arm64"
if value.startswith("arm"):
return "arm64" if bitness == 64 else "arm"
if value in {"mips", "mips32", "mipsel", "mipseb"}:
return "mips"
if value in {"mips64", "mips64el", "mips64eb"}:
return "mips64"
if value in {"ppc", "powerpc"}:
return "ppc"
if value in {"ppc64", "powerpc64", "ppc64le"}:
return "ppc64"
if value in {"riscv", "riscv32"}:
return "riscv"
if value == "riscv64":
return "riscv64"
if value == "sparc":
return "sparc"
if value == "sparc64":
return "sparc64"
return None
@staticmethod
def _map_ida_filetype(filetype: int) -> Tuple[Optional[str], Optional[str]]:
platform_map = {
3: "raw",
11: "linux",
17: "windows",
20: "macos",
}
format_map = {
3: "bin",
11: "elf",
16: "coff",
17: "pe",
20: "macho",
}
return platform_map.get(filetype), format_map.get(filetype)
def _get_original_binary_metadata(self) -> Dict[str, Any]:
"""Build metadata for the original input binary, not the IDA database file."""
metadata: Dict[str, Any] = {}
if not _IN_IDA:
return metadata
input_path = ida_nalt.get_input_file_path() or ""
file_name = self._basename_only(input_path)
if not file_name:
candidate_name = self._basename_only(ida_nalt.get_root_filename() or "")
if candidate_name and not self._is_analysis_container_name(candidate_name):
file_name = candidate_name
if file_name and not self._is_analysis_container_name(file_name):
metadata['file_name'] = file_name
if input_path and os.path.exists(input_path):
try:
metadata['file_size'] = int(os.path.getsize(input_path))
except OSError:
pass
try:
bitness = 64 if ida_ida.inf_is_64bit() else 32
arch = self._normalize_architecture(ida_ida.inf_get_procname() or None, bitness)
if arch:
metadata['architecture'] = arch
except Exception:
pass
try:
platform, file_format = self._map_ida_filetype(ida_ida.inf_get_filetype())
if platform:
metadata['platform'] = platform
if file_format:
metadata['file_format'] = file_format
except Exception:
pass
try:
metadata['endianness'] = 'big' if ida_ida.inf_is_be() else 'little'
except Exception:
pass
return metadata
def _get_sha256(self) -> Optional[str]:
"""Get SHA256 hash of the original binary."""
return get_binary_hash() or None
@@ -769,17 +917,23 @@ class SymGraphController(QObject):
self.view.hide_stats()
self.view.set_open_binary_url(None)
self.view.set_buttons_enabled(False)
self._active_query_sha = sha256
# Start query worker
self.query_worker = QueryWorker(sha256)
self.query_worker.query_complete.connect(self._on_query_complete)
self.query_worker.query_error.connect(self._on_query_error)
self.query_worker.finished.connect(lambda: self.view.set_buttons_enabled(True))
self.query_worker.query_complete.connect(
lambda result, expected_sha=sha256: self._on_query_complete(expected_sha, result)
)
self.query_worker.query_error.connect(
lambda error_msg, expected_sha=sha256: self._on_query_error(expected_sha, error_msg)
)
self.query_worker.finished.connect(lambda expected_sha=sha256: self._on_query_finished(expected_sha))
self.query_worker.start()
def _on_query_complete(self, result: QueryResult):
def _on_query_complete(self, expected_sha: str, result: QueryResult):
"""Handle query completion."""
self.view.set_buttons_enabled(True)
if expected_sha != self._active_query_sha or expected_sha != self._get_sha256():
return
if result.error:
self.view.set_query_status(f"Error: {result.error}", found=False)
@@ -794,6 +948,7 @@ class SymGraphController(QObject):
symbols=result.stats.symbol_count,
functions=result.stats.function_count,
nodes=result.stats.graph_node_count,
edges=result.stats.graph_edge_count,
last_updated=result.stats.last_queried_at,
revisions=result.revisions,
latest_revision=result.latest_revision,
@@ -804,13 +959,19 @@ class SymGraphController(QObject):
self.view.set_open_binary_url(None)
self.view.hide_stats()
def _on_query_error(self, error_msg: str):
def _on_query_error(self, expected_sha: str, error_msg: str):
"""Handle query error."""
self.view.set_buttons_enabled(True)
if expected_sha != self._active_query_sha or expected_sha != self._get_sha256():
return
self.view.set_query_status(f"Error: {error_msg}", found=False)
self.view.set_open_binary_url(None)
log.log_error(f"Query error: {error_msg}")
def _on_query_finished(self, expected_sha: str):
if expected_sha != self._active_query_sha:
return
self.view.set_buttons_enabled(True)
def handle_open_binary(self):
"""Open the current binary in the SymGraph web UI."""
url = self.view.get_open_binary_url()
@@ -929,6 +1090,7 @@ class SymGraphController(QObject):
'visibility': visibility
}
self._pending_push_documents = list(selected_documents)
binary_metadata = self._get_original_binary_metadata()
# Start push worker
self.push_worker = PushWorker(
@@ -937,7 +1099,8 @@ class SymGraphController(QObject):
document_payloads,
graph_data,
visibility=visibility,
fingerprints=fingerprints
fingerprints=fingerprints,
binary_metadata=binary_metadata
)
self.push_worker.push_complete.connect(self._on_push_complete)
self.push_worker.push_error.connect(self._on_push_error)
@@ -1122,7 +1285,7 @@ class SymGraphController(QObject):
def _on_pull_preview_finished(self):
"""Handle pull preview worker finished (cleanup)."""
self.view.set_buttons_enabled(True)
self.view.set_pull_button_text("Preview Fetch")
self.view.set_pull_button_text("Preview Import")
def _on_pull_preview_error(self, error_msg: str):
"""Handle pull preview error."""
+3
View File
@@ -76,6 +76,7 @@ class BinaryStats:
symbol_count: int = 0
function_count: int = 0
graph_node_count: int = 0
graph_edge_count: int = 0
query_count: int = 0
last_queried_at: Optional[str] = None
@@ -95,6 +96,7 @@ class BinaryStats:
symbol_count=stats_data.get('symbol_count', 0),
function_count=stats_data.get('function_count', 0),
graph_node_count=stats_data.get('graph_node_count', 0),
graph_edge_count=stats_data.get('graph_edge_count', 0),
query_count=stats_data.get('query_count', 0),
last_queried_at=last_queried
)
@@ -105,6 +107,7 @@ class BinaryStats:
'symbol_count': self.symbol_count,
'function_count': self.function_count,
'graph_node_count': self.graph_node_count,
'graph_edge_count': self.graph_edge_count,
'query_count': self.query_count,
'last_queried_at': self.last_queried_at
}
+13
View File
@@ -210,6 +210,11 @@ class SettingsService:
'value': '',
'type': 'string',
'category': 'symgraph'
},
'symgraph_auto_refresh': {
'value': '0',
'type': 'boolean',
'category': 'symgraph'
}
}
@@ -855,6 +860,14 @@ class SettingsService:
key = self.get_symgraph_api_key()
return bool(key and key.strip())
def is_symgraph_auto_refresh_enabled(self) -> bool:
"""Check if SymGraph status auto-refresh is enabled."""
return self.get_setting('symgraph_auto_refresh', False)
def set_symgraph_auto_refresh_enabled(self, enabled: bool) -> bool:
"""Persist the SymGraph status auto-refresh setting."""
return self.set_setting('symgraph_auto_refresh', enabled, 'symgraph')
def close(self):
"""Close database connections (for cleanup)"""
pass
+47
View File
@@ -575,6 +575,53 @@ class SymGraphService:
except httpx.RequestError as e:
raise SymGraphNetworkError(f"Network error: {str(e)}")
async def update_binary_metadata(
self,
sha256: str,
metadata: Dict[str, Any]
) -> Dict[str, Any]:
"""Update top-level binary metadata for an existing SymGraph binary."""
self._check_httpx()
self._check_auth()
payload: Dict[str, Any] = {}
for key, value in (metadata or {}).items():
if value is None:
continue
if isinstance(value, str):
cleaned = value.strip()
if not cleaned:
continue
payload[key] = cleaned
continue
payload[key] = value
if not payload:
return {}
url = f"{self.base_url}/api/v1/binaries/{sha256}"
log.log_debug(f"Updating binary metadata for {sha256[:16]}... keys={sorted(payload.keys())}")
try:
async with httpx.AsyncClient(**self._client_kwargs(60.0)) as client:
response = await client.patch(
url,
headers=self._get_headers(authenticated=True),
json=payload
)
if response.status_code == 200:
return response.json() if response.content else {}
elif response.status_code == 401:
raise SymGraphAuthError("Invalid API key")
else:
self._raise_api_error("updating binary metadata", response)
except httpx.TimeoutException:
raise SymGraphNetworkError(f"Timeout connecting to {self.base_url}")
except httpx.RequestError as e:
raise SymGraphNetworkError(f"Network error: {str(e)}")
async def push_symbols_bulk(
self,
sha256: str,
+342 -139
View File
@@ -26,9 +26,9 @@ from ..qt_compat import (
QTableWidget,
QTableWidgetItem,
QTabWidget,
QToolButton,
QVBoxLayout,
QWidget,
QFont,
Signal,
Qt,
)
@@ -48,6 +48,7 @@ class SymGraphTabView(QWidget):
)
query_requested = Signal()
auto_refresh_changed = Signal(bool)
open_binary_requested = Signal()
pull_preview_requested = Signal()
apply_selected_requested = Signal(list)
@@ -63,6 +64,8 @@ class SymGraphTabView(QWidget):
super().__init__()
self._graph_preview = None
self._graph_stats = None
self._all_conflicts: List[ConflictEntry] = []
self._conflict_selection_state: Dict[Any, bool] = {}
self._push_graph_data = None
self._push_graph_stats = None
self._push_preview_symbols: List[Dict[str, Any]] = []
@@ -74,82 +77,87 @@ class SymGraphTabView(QWidget):
def _build_ui(self):
layout = QVBoxLayout()
layout.setContentsMargins(5, 5, 5, 5)
layout.setSpacing(5)
layout.setContentsMargins(8, 8, 8, 8)
layout.setSpacing(8)
self._create_binary_info_section(layout)
self._create_overview_section(layout)
self.subtabs = QTabWidget()
self.subtabs.addTab(self._create_status_tab(), "Status")
self.subtabs.addTab(self._create_fetch_tab(), "Fetch")
self.subtabs.addTab(self._create_push_tab(), "Push")
layout.addWidget(self.subtabs)
self.workflow_tabs = QTabWidget()
self.workflow_tabs.addTab(self._create_fetch_tab(), "Import From SymGraph")
self.workflow_tabs.addTab(self._create_push_tab(), "Publish To SymGraph")
layout.addWidget(self.workflow_tabs, 1)
self.setLayout(layout)
def _create_binary_info_section(self, parent_layout):
info_frame = QFrame()
info_frame.setFrameStyle(QFrame.StyledPanel | QFrame.Raised)
info_layout = QVBoxLayout(info_frame)
info_layout.setContentsMargins(10, 10, 10, 10)
def _create_overview_section(self, parent_layout):
container = QWidget()
container_layout = QVBoxLayout(container)
container_layout.setContentsMargins(0, 0, 0, 0)
container_layout.setSpacing(8)
name_layout = QHBoxLayout()
name_layout.addWidget(QLabel("Binary:"))
local_frame = QFrame()
local_frame.setFrameStyle(QFrame.Box | QFrame.Sunken)
local_layout = QVBoxLayout(local_frame)
local_layout.setContentsMargins(10, 10, 10, 10)
local_layout.setSpacing(8)
local_header = QHBoxLayout()
local_header.addWidget(QLabel("Local Status"))
local_header.addStretch()
local_layout.addLayout(local_header)
identity_grid = QGridLayout()
identity_grid.setHorizontalSpacing(10)
identity_grid.setVerticalSpacing(4)
identity_grid.addWidget(QLabel("Binary"), 0, 0)
self.binary_name_label = QLabel("<no binary loaded>")
self.binary_name_label.setStyleSheet("font-weight: bold;")
name_layout.addWidget(self.binary_name_label)
name_layout.addStretch()
info_layout.addLayout(name_layout)
hash_layout = QHBoxLayout()
hash_layout.addWidget(QLabel("SHA256:"))
self.binary_name_label.setStyleSheet("font-weight: 600;")
identity_grid.addWidget(self.binary_name_label, 0, 1)
identity_grid.addWidget(QLabel("SHA256"), 1, 0)
self.sha256_label = QLabel("<none>")
self.sha256_label.setStyleSheet("font-family: monospace; font-size: 11px;")
hash_layout.addWidget(self.sha256_label)
hash_layout.addStretch()
info_layout.addLayout(hash_layout)
identity_grid.addWidget(self.sha256_label, 1, 1)
local_layout.addLayout(identity_grid)
summary_layout = QHBoxLayout()
summary_layout.addWidget(QLabel("Local Summary:"))
self.local_summary_label = QLabel("No binary loaded")
self.local_summary_label.setStyleSheet("color: gray;")
summary_layout.addWidget(self.local_summary_label)
summary_layout.addStretch()
info_layout.addLayout(summary_layout)
self.local_summary_label.setProperty("role", "muted")
self.local_summary_label.setStyleSheet("color: palette(mid);")
local_layout.addWidget(self.local_summary_label)
parent_layout.addWidget(info_frame)
remote_frame = QFrame()
remote_frame.setFrameStyle(QFrame.Box | QFrame.Sunken)
remote_layout = QVBoxLayout(remote_frame)
remote_layout.setContentsMargins(10, 10, 10, 10)
remote_layout.setSpacing(8)
def _create_status_tab(self) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
server_group = QGroupBox("SymGraph Server")
server_layout = QVBoxLayout()
button_row = QHBoxLayout()
self.query_button = QPushButton("Query SymGraph")
remote_header = QHBoxLayout()
remote_header.addWidget(QLabel("Remote Status"))
self.status_badge = QLabel("Not checked")
self.status_badge.setStyleSheet("padding: 2px 8px; background: palette(midlight); color: palette(text);")
remote_header.addWidget(self.status_badge)
remote_header.addStretch()
self.auto_refresh_checkbox = QCheckBox("Auto-refresh")
self.auto_refresh_checkbox.setChecked(False)
self.auto_refresh_checkbox.toggled.connect(self.auto_refresh_changed.emit)
remote_header.addWidget(self.auto_refresh_checkbox)
self.query_button = QPushButton("Refresh")
self.query_button.clicked.connect(self.query_requested.emit)
button_row.addWidget(self.query_button)
remote_header.addWidget(self.query_button)
self.open_binary_button = QPushButton("Open in SymGraph")
self.open_binary_button.setEnabled(False)
self.open_binary_button.clicked.connect(self.open_binary_requested.emit)
button_row.addWidget(self.open_binary_button)
button_row.addStretch()
server_layout.addLayout(button_row)
remote_header.addWidget(self.open_binary_button)
remote_layout.addLayout(remote_header)
status_row = QHBoxLayout()
status_row.addWidget(QLabel("Status:"))
self.status_label = QLabel("Not checked")
self.status_label.setStyleSheet("color: gray;")
status_row.addWidget(self.status_label)
status_row.addStretch()
server_layout.addLayout(status_row)
self.status_label = QLabel("Use Refresh to check whether this binary already exists in SymGraph.")
self.status_label.setWordWrap(True)
remote_layout.addWidget(self.status_label)
self.stats_frame = QFrame()
self.stats_frame.setFrameStyle(QFrame.Box | QFrame.Sunken)
stats_layout = QGridLayout(self.stats_frame)
stats_layout.setContentsMargins(0, 0, 0, 0)
stats_layout.setHorizontalSpacing(18)
stats_layout.setVerticalSpacing(4)
self.symbols_stat = QLabel("Symbols: -")
self.functions_stat = QLabel("Functions: -")
self.nodes_stat = QLabel("Graph Nodes: -")
@@ -165,11 +173,11 @@ class SymGraphTabView(QWidget):
stats_layout.addWidget(self.latest_revision_stat, 2, 1)
stats_layout.addWidget(self.accessible_versions_stat, 3, 0, 1, 2)
self.stats_frame.setVisible(False)
server_layout.addWidget(self.stats_frame)
server_group.setLayout(server_layout)
layout.addWidget(server_group)
layout.addStretch()
return page
remote_layout.addWidget(self.stats_frame)
container_layout.addWidget(local_frame)
container_layout.addWidget(remote_frame)
parent_layout.addWidget(container)
def _create_fetch_tab(self) -> QWidget:
page = QWidget()
@@ -177,24 +185,19 @@ class SymGraphTabView(QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
config_group = QGroupBox("Fetch Configuration")
config_group = QGroupBox("Import Configuration")
config_layout = QVBoxLayout()
row1 = QHBoxLayout()
row1.addWidget(QLabel("Version:"))
row1.addWidget(QLabel("Source Revision:"))
self.fetch_version_combo = QComboBox()
self.fetch_version_combo.setMinimumWidth(180)
row1.addWidget(self.fetch_version_combo)
row1.addSpacing(12)
row1.addWidget(QLabel("Name Filter:"))
self.fetch_name_filter = QLineEdit()
self.fetch_name_filter.setPlaceholderText("Substring match")
row1.addWidget(self.fetch_name_filter)
row1.addStretch()
config_layout.addLayout(row1)
row2 = QHBoxLayout()
row2.addWidget(QLabel("Symbol Types:"))
row2.addWidget(QLabel("Include:"))
self.pull_functions_cb = QCheckBox("Functions")
self.pull_functions_cb.setChecked(True)
self.pull_variables_cb = QCheckBox("Variables")
@@ -214,27 +217,41 @@ class SymGraphTabView(QWidget):
row2.addStretch()
config_layout.addLayout(row2)
row3 = QHBoxLayout()
row3.addWidget(QLabel("Min Confidence:"))
advanced_row = QHBoxLayout()
self.fetch_advanced_toggle = self._create_disclosure_button("Advanced Filters")
advanced_row.addWidget(self.fetch_advanced_toggle)
advanced_row.addStretch()
config_layout.addLayout(advanced_row)
self.fetch_advanced_panel = QWidget()
fetch_advanced_layout = QGridLayout(self.fetch_advanced_panel)
fetch_advanced_layout.setContentsMargins(0, 0, 0, 0)
fetch_advanced_layout.setHorizontalSpacing(12)
fetch_advanced_layout.setVerticalSpacing(6)
fetch_advanced_layout.addWidget(QLabel("Name Filter:"), 0, 0)
self.fetch_name_filter = QLineEdit()
self.fetch_name_filter.setPlaceholderText("Substring match")
fetch_advanced_layout.addWidget(self.fetch_name_filter, 0, 1)
fetch_advanced_layout.addWidget(QLabel("Min Confidence:"), 1, 0)
self.confidence_slider = QSlider(Qt.Horizontal)
self.confidence_slider.setMinimum(0)
self.confidence_slider.setMaximum(100)
self.confidence_slider.setValue(0)
self.confidence_slider.setFixedWidth(140)
self.confidence_slider.valueChanged.connect(self._on_confidence_changed)
row3.addWidget(self.confidence_slider)
fetch_advanced_layout.addWidget(self.confidence_slider, 1, 1)
self.confidence_value_label = QLabel("0.0")
self.confidence_value_label.setFixedWidth(30)
row3.addWidget(self.confidence_value_label)
row3.addSpacing(16)
row3.addWidget(QLabel("Graph Merge:"))
fetch_advanced_layout.addWidget(self.confidence_value_label, 1, 2)
fetch_advanced_layout.addWidget(QLabel("Graph Merge:"), 2, 0)
merge_widget, _ = self._create_merge_policy_widget()
row3.addWidget(merge_widget)
row3.addStretch()
config_layout.addLayout(row3)
fetch_advanced_layout.addWidget(merge_widget, 2, 1, 1, 2)
self.fetch_advanced_panel.setVisible(False)
self.fetch_advanced_toggle.toggled.connect(self.fetch_advanced_panel.setVisible)
config_layout.addWidget(self.fetch_advanced_panel)
action_row = QHBoxLayout()
self.pull_preview_button = QPushButton("Preview Fetch")
self.pull_preview_button = QPushButton("Preview Import")
self.pull_preview_button.clicked.connect(self.pull_preview_requested.emit)
action_row.addWidget(self.pull_preview_button)
self.fetch_reset_button = QPushButton("Reset")
@@ -252,7 +269,7 @@ class SymGraphTabView(QWidget):
self.summary_new_count = QLabel("New: 0")
self.summary_conflict_count = QLabel("Conflicts: 0")
self.summary_same_count = QLabel("Same: 0")
self.summary_selected_count = QLabel("Selected: 0")
self.summary_selected_count = QLabel("Selected: 0 symbols / 0 docs")
self.summary_document_count = QLabel("Documents: 0")
self.summary_graph_nodes_label = QLabel("Graph Nodes: 0")
self.summary_graph_edges_label = QLabel("Graph Edges: 0")
@@ -271,10 +288,31 @@ class SymGraphTabView(QWidget):
summary_layout.addStretch()
layout.addWidget(self.fetch_summary_frame)
preview_tabs = QTabWidget()
changes_page = QWidget()
changes_layout = QVBoxLayout(changes_page)
changes_layout.setContentsMargins(0, 0, 0, 0)
changes_layout.setSpacing(8)
filter_row = QHBoxLayout()
filter_row.addWidget(QLabel("Show:"))
self.filter_new_cb = QCheckBox("New")
self.filter_new_cb.setChecked(True)
self.filter_conflicts_cb = QCheckBox("Conflicts")
self.filter_conflicts_cb.setChecked(True)
self.filter_same_cb = QCheckBox("Unchanged")
self.filter_same_cb.setChecked(False)
for widget in (self.filter_new_cb, self.filter_conflicts_cb, self.filter_same_cb):
widget.toggled.connect(self._refresh_conflict_table)
filter_row.addWidget(widget)
filter_row.addStretch()
changes_layout.addLayout(filter_row)
self.conflict_table = QTableWidget()
self.conflict_table.setColumnCount(6)
self.conflict_table.setHorizontalHeaderLabels(
["Select", "Address", "Type/Storage", "Local Name", "Remote Name", "Action"]
["Select", "Address", "Type/Storage", "Local Name", "Remote Name", "Status"]
)
self.conflict_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.conflict_table.setSelectionMode(QAbstractItemView.ExtendedSelection)
@@ -290,9 +328,13 @@ class SymGraphTabView(QWidget):
self.conflict_table.setColumnWidth(2, 130)
self.conflict_table.setColumnWidth(5, 90)
self.conflict_table.setMinimumHeight(250)
layout.addWidget(self.conflict_table)
self.conflict_table.setAlternatingRowColors(True)
changes_layout.addWidget(self.conflict_table)
footer_row = QHBoxLayout()
select_row = QHBoxLayout()
select_row.setContentsMargins(0, 0, 0, 0)
select_row.setSpacing(6)
self.select_all_button = QPushButton("Select All")
self.select_all_button.clicked.connect(self.on_select_all)
select_row.addWidget(self.select_all_button)
@@ -308,19 +350,26 @@ class SymGraphTabView(QWidget):
self.invert_selection_button = QPushButton("Invert")
self.invert_selection_button.clicked.connect(self.on_invert_selection)
select_row.addWidget(self.invert_selection_button)
select_row.addStretch()
layout.addLayout(select_row)
footer_row.addLayout(select_row)
footer_row.addStretch()
apply_row = QHBoxLayout()
self.apply_all_new_button = QPushButton("Apply All New")
apply_row.setContentsMargins(0, 0, 0, 0)
apply_row.setSpacing(6)
self.apply_all_new_button = QPushButton("Apply Recommended")
self.apply_all_new_button.clicked.connect(self.apply_all_new_requested.emit)
apply_row.addWidget(self.apply_all_new_button)
self.apply_button = QPushButton("Apply Selected")
self.apply_button.clicked.connect(self.on_apply_clicked)
apply_row.addWidget(self.apply_button)
apply_row.addStretch()
layout.addLayout(apply_row)
footer_row.addLayout(apply_row)
changes_layout.addLayout(footer_row)
preview_tabs.addTab(changes_page, "Changes")
documents_page = QWidget()
documents_layout = QVBoxLayout(documents_page)
documents_layout.setContentsMargins(0, 0, 0, 0)
documents_layout.setSpacing(8)
self.fetch_documents_table = QTableWidget()
self.fetch_documents_table.setColumnCount(5)
self.fetch_documents_table.setHorizontalHeaderLabels(
@@ -338,9 +387,21 @@ class SymGraphTabView(QWidget):
self.fetch_documents_table.setColumnWidth(2, 90)
self.fetch_documents_table.setColumnWidth(3, 140)
self.fetch_documents_table.setColumnWidth(4, 80)
self.fetch_documents_table.setMaximumHeight(150)
layout.addWidget(QLabel("Documents"))
layout.addWidget(self.fetch_documents_table)
self.fetch_documents_table.setAlternatingRowColors(True)
documents_layout.addWidget(self.fetch_documents_table)
preview_tabs.addTab(documents_page, "Documents")
graph_page = QWidget()
graph_layout = QVBoxLayout(graph_page)
graph_layout.setContentsMargins(0, 0, 0, 0)
graph_layout.setSpacing(8)
self.fetch_graph_summary_label = QLabel("No graph data loaded.")
self.fetch_graph_summary_label.setWordWrap(True)
graph_layout.addWidget(self.fetch_graph_summary_label)
graph_layout.addStretch()
preview_tabs.addTab(graph_page, "Graph")
layout.addWidget(preview_tabs, 1)
self.fetch_progress_bar = QProgressBar()
self.fetch_progress_bar.setVisible(False)
@@ -352,6 +413,7 @@ class SymGraphTabView(QWidget):
layout.addWidget(self.fetch_progress_label)
self.pull_status_label = QLabel("")
self.pull_status_label.setWordWrap(True)
self.pull_status_label.setStyleSheet("color: gray;")
layout.addWidget(self.pull_status_label)
return page
@@ -362,7 +424,7 @@ class SymGraphTabView(QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
config_group = QGroupBox("Push Configuration")
config_group = QGroupBox("Publish Configuration")
config_layout = QVBoxLayout()
row1 = QHBoxLayout()
@@ -385,7 +447,7 @@ class SymGraphTabView(QWidget):
config_layout.addLayout(row1)
row2 = QHBoxLayout()
row2.addWidget(QLabel("Symbol Types:"))
row2.addWidget(QLabel("Include:"))
self.push_functions_cb = QCheckBox("Functions")
self.push_functions_cb.setChecked(True)
self.push_variables_cb = QCheckBox("Variables")
@@ -408,21 +470,31 @@ class SymGraphTabView(QWidget):
row2.addStretch()
config_layout.addLayout(row2)
row3 = QHBoxLayout()
row3.addWidget(QLabel("Name Filter:"))
advanced_row = QHBoxLayout()
self.push_advanced_toggle = self._create_disclosure_button("Advanced Filters")
advanced_row.addWidget(self.push_advanced_toggle)
advanced_row.addStretch()
config_layout.addLayout(advanced_row)
self.push_advanced_panel = QWidget()
push_advanced_layout = QGridLayout(self.push_advanced_panel)
push_advanced_layout.setContentsMargins(0, 0, 0, 0)
push_advanced_layout.setHorizontalSpacing(12)
push_advanced_layout.addWidget(QLabel("Name Filter:"), 0, 0)
self.push_name_filter = QLineEdit()
self.push_name_filter.setPlaceholderText("Substring match")
row3.addWidget(self.push_name_filter)
row3.addStretch()
config_layout.addLayout(row3)
push_advanced_layout.addWidget(self.push_name_filter, 0, 1)
self.push_advanced_panel.setVisible(False)
self.push_advanced_toggle.toggled.connect(self.push_advanced_panel.setVisible)
config_layout.addWidget(self.push_advanced_panel)
action_row = QHBoxLayout()
self.push_preview_button = QPushButton("Preview Push")
self.push_preview_button = QPushButton("Preview Publish")
self.push_preview_button.clicked.connect(self.push_preview_requested.emit)
action_row.addWidget(self.push_preview_button)
self.push_button = QPushButton("Push Selected")
self.push_button.clicked.connect(self.push_execute_requested.emit)
action_row.addWidget(self.push_button)
self.push_reset_button = QPushButton("Reset")
self.push_reset_button.clicked.connect(self.clear_push_preview)
action_row.addWidget(self.push_reset_button)
action_row.addStretch()
config_layout.addLayout(action_row)
config_group.setLayout(config_layout)
@@ -447,6 +519,12 @@ class SymGraphTabView(QWidget):
push_summary_layout.addStretch()
layout.addWidget(self.push_summary_frame)
push_preview_tabs = QTabWidget()
push_symbols_page = QWidget()
push_symbols_layout = QVBoxLayout(push_symbols_page)
push_symbols_layout.setContentsMargins(0, 0, 0, 0)
push_symbols_layout.setSpacing(8)
self.push_preview_table = QTableWidget()
self.push_preview_table.setColumnCount(6)
self.push_preview_table.setHorizontalHeaderLabels(
@@ -467,9 +545,13 @@ class SymGraphTabView(QWidget):
self.push_preview_table.setColumnWidth(4, 90)
self.push_preview_table.setColumnWidth(5, 100)
self.push_preview_table.setMinimumHeight(250)
layout.addWidget(self.push_preview_table)
self.push_preview_table.setAlternatingRowColors(True)
push_symbols_layout.addWidget(self.push_preview_table)
push_footer_row = QHBoxLayout()
push_select_row = QHBoxLayout()
push_select_row.setContentsMargins(0, 0, 0, 0)
push_select_row.setSpacing(6)
self.push_select_all_button = QPushButton("Select All")
self.push_select_all_button.clicked.connect(self.on_push_select_all)
push_select_row.addWidget(self.push_select_all_button)
@@ -479,9 +561,22 @@ class SymGraphTabView(QWidget):
self.push_invert_selection_button = QPushButton("Invert")
self.push_invert_selection_button.clicked.connect(self.on_push_invert_selection)
push_select_row.addWidget(self.push_invert_selection_button)
push_select_row.addStretch()
layout.addLayout(push_select_row)
push_footer_row.addLayout(push_select_row)
push_footer_row.addStretch()
push_action_row = QHBoxLayout()
push_action_row.setContentsMargins(0, 0, 0, 0)
push_action_row.setSpacing(6)
self.push_button = QPushButton("Publish Selected")
self.push_button.clicked.connect(self.push_execute_requested.emit)
push_action_row.addWidget(self.push_button)
push_footer_row.addLayout(push_action_row)
push_symbols_layout.addLayout(push_footer_row)
push_preview_tabs.addTab(push_symbols_page, "Symbols")
push_documents_page = QWidget()
push_documents_layout = QVBoxLayout(push_documents_page)
push_documents_layout.setContentsMargins(0, 0, 0, 0)
push_documents_layout.setSpacing(8)
self.push_documents_table = QTableWidget()
self.push_documents_table.setColumnCount(6)
self.push_documents_table.setHorizontalHeaderLabels(
@@ -501,9 +596,21 @@ class SymGraphTabView(QWidget):
self.push_documents_table.setColumnWidth(3, 140)
self.push_documents_table.setColumnWidth(4, 80)
self.push_documents_table.setColumnWidth(5, 110)
self.push_documents_table.setMaximumHeight(150)
layout.addWidget(QLabel("Documents"))
layout.addWidget(self.push_documents_table)
self.push_documents_table.setAlternatingRowColors(True)
push_documents_layout.addWidget(self.push_documents_table)
push_preview_tabs.addTab(push_documents_page, "Documents")
push_graph_page = QWidget()
push_graph_layout = QVBoxLayout(push_graph_page)
push_graph_layout.setContentsMargins(0, 0, 0, 0)
push_graph_layout.setSpacing(8)
self.push_graph_summary_label = QLabel("No graph data included in this publish preview.")
self.push_graph_summary_label.setWordWrap(True)
push_graph_layout.addWidget(self.push_graph_summary_label)
push_graph_layout.addStretch()
push_preview_tabs.addTab(push_graph_page, "Graph")
layout.addWidget(push_preview_tabs, 1)
self.push_progress_bar = QProgressBar()
self.push_progress_bar.setVisible(False)
@@ -515,10 +622,23 @@ class SymGraphTabView(QWidget):
layout.addWidget(self.push_progress_label)
self.push_status_label = QLabel("Status: Ready")
self.push_status_label.setWordWrap(True)
self.push_status_label.setStyleSheet("color: gray;")
layout.addWidget(self.push_status_label)
return page
def _create_disclosure_button(self, label: str) -> QToolButton:
button = QToolButton()
button.setText(label)
button.setCheckable(True)
button.setChecked(False)
button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
button.setArrowType(Qt.RightArrow)
button.toggled.connect(
lambda checked, target=button: target.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
)
return button
def _create_merge_policy_widget(self):
widget = QWidget()
layout = QHBoxLayout(widget)
@@ -607,10 +727,12 @@ class SymGraphTabView(QWidget):
except (TypeError, ValueError):
return 0
def _set_checkbox_widget(self, table: QTableWidget, row: int, checked: bool):
def _set_checkbox_widget(self, table: QTableWidget, row: int, checked: bool, on_change=None):
checkbox = QCheckBox()
checkbox.setChecked(checked)
checkbox.stateChanged.connect(self._update_selection_summaries)
if on_change is not None:
checkbox.stateChanged.connect(on_change)
container = QWidget()
container_layout = QHBoxLayout(container)
container_layout.setContentsMargins(0, 0, 0, 0)
@@ -653,6 +775,36 @@ class SymGraphTabView(QWidget):
f"Selected: {selected_push_symbols} symbols / {selected_push_documents} docs"
)
@staticmethod
def _conflict_key(conflict: ConflictEntry):
remote_name = conflict.remote_name or ""
local_name = conflict.local_name or ""
return (conflict.address, conflict.action.value, remote_name, local_name)
def _on_conflict_checkbox_changed(self, key, state: int):
self._conflict_selection_state[key] = state == Qt.Checked
def _filtered_conflicts(self) -> List[ConflictEntry]:
allowed_actions = set()
if self.filter_new_cb.isChecked():
allowed_actions.add(ConflictAction.NEW)
if self.filter_conflicts_cb.isChecked():
allowed_actions.add(ConflictAction.CONFLICT)
if self.filter_same_cb.isChecked():
allowed_actions.add(ConflictAction.SAME)
return [conflict for conflict in self._all_conflicts if conflict.action in allowed_actions]
def _refresh_conflict_table(self):
if not hasattr(self, "conflict_table"):
return
self.conflict_table.setRowCount(0)
for conflict in self._filtered_conflicts():
self._add_conflict_row(conflict)
self.apply_button.setEnabled(
bool(self._all_conflicts) or self._graph_preview is not None or bool(self._pull_preview_documents)
)
self._update_selection_summaries()
def set_binary_info(self, name: str, sha256: str, local_metadata: Optional[Dict[str, Any]] = None):
display_name = name or "<no binary loaded>"
display_sha = sha256 or "<none>"
@@ -678,17 +830,44 @@ class SymGraphTabView(QWidget):
def set_query_status(self, status: str, found: bool = False):
self.status_label.setText(status)
if found:
self.status_label.setStyleSheet("color: green; font-weight: bold;")
self.status_label.setStyleSheet("color: green;")
self.status_badge.setText("Found")
self.status_badge.setStyleSheet(
"padding: 2px 8px; background: #1f6f3d; color: white; font-weight: 600;"
)
elif "error" in status.lower() or "not found" in status.lower():
self.status_label.setStyleSheet("color: red;")
if "not found" in status.lower():
self.status_badge.setText("Not Found")
self.status_badge.setStyleSheet(
"padding: 2px 8px; background: #8b2e2e; color: white; font-weight: 600;"
)
else:
self.status_badge.setText("Error")
self.status_badge.setStyleSheet(
"padding: 2px 8px; background: #8b2e2e; color: white; font-weight: 600;"
)
else:
self.status_label.setStyleSheet("color: gray;")
self.status_badge.setText("Checking" if "check" in status.lower() else "Unknown")
self.status_badge.setStyleSheet(
"padding: 2px 8px; background: palette(midlight); color: palette(text);"
)
def reset_query_status(self):
self.status_badge.setText("Not checked")
self.status_badge.setStyleSheet(
"padding: 2px 8px; background: palette(midlight); color: palette(text);"
)
self.status_label.setText("Use Refresh to check whether this binary already exists in SymGraph.")
self.status_label.setStyleSheet("color: gray;")
def set_stats(
self,
symbols: int,
functions: int,
nodes: int,
edges: int,
last_updated: str,
revisions: Optional[List[BinaryRevision]] = None,
latest_revision: Optional[int] = None,
@@ -697,7 +876,7 @@ class SymGraphTabView(QWidget):
self.symbols_stat.setText(f"Symbols: {symbols:,}")
self.functions_stat.setText(f"Functions: {functions:,}")
self.nodes_stat.setText(f"Graph Nodes: {nodes:,}")
self.edges_stat.setText("Graph Edges: -")
self.edges_stat.setText(f"Graph Edges: {edges:,}")
self.updated_stat.setText(f"Last Updated: {last_updated or 'Unknown'}")
self.latest_revision_stat.setText(
f"Latest Version: v{latest_revision}" if latest_revision else "Latest Version: -"
@@ -739,6 +918,14 @@ class SymGraphTabView(QWidget):
def get_open_binary_url(self) -> Optional[str]:
return self._open_binary_url
def set_auto_refresh_enabled(self, enabled: bool):
self.auto_refresh_checkbox.blockSignals(True)
self.auto_refresh_checkbox.setChecked(enabled)
self.auto_refresh_checkbox.blockSignals(False)
def is_auto_refresh_enabled(self) -> bool:
return self.auto_refresh_checkbox.isChecked()
def set_push_status(self, status: str, success: bool = None):
self.push_status_label.setText(f"Status: {status}")
if success is True:
@@ -768,12 +955,29 @@ class SymGraphTabView(QWidget):
self.summary_graph_version_label.setText(
f"Version: v{version}" if version is not None else "Version: Latest"
)
if graph_export:
communities = self._graph_stats.get("communities", 0)
self.fetch_graph_summary_label.setText(
f"Graph preview ready from {self.summary_graph_version_label.text().replace('Version: ', '')}: "
f"{nodes:,} nodes, {edges:,} edges"
+ (f", {communities:,} communities" if communities else "")
+ f". Merge policy: {self._merge_policy.replace('_', ' ')}."
)
else:
self.fetch_graph_summary_label.setText("No graph data loaded.")
def clear_graph_preview_data(self):
self.set_graph_preview_data(None, {})
def populate_conflicts(self, conflicts: List[ConflictEntry]):
self.conflict_table.setRowCount(0)
self._all_conflicts = sorted(
conflicts,
key=lambda item: (
0 if item.action == ConflictAction.NEW else 1 if item.action == ConflictAction.CONFLICT else 2,
item.address,
),
)
self._conflict_selection_state = {}
new_count = sum(1 for c in conflicts if c.action == ConflictAction.NEW)
conflict_count = sum(1 for c in conflicts if c.action == ConflictAction.CONFLICT)
@@ -782,27 +986,12 @@ class SymGraphTabView(QWidget):
self.summary_conflict_count.setText(f"Conflicts: {conflict_count}")
self.summary_same_count.setText(f"Same: {same_count}")
actionable = sorted(
[c for c in conflicts if c.action != ConflictAction.SAME],
key=lambda item: (0 if item.action == ConflictAction.NEW else 1, item.address)
)
preselected_ids = {id(item) for item in actionable}
sorted_conflicts = sorted(
conflicts,
key=lambda item: (
0 if item.action == ConflictAction.NEW else 1 if item.action == ConflictAction.CONFLICT else 2,
item.address,
),
)
for conflict in sorted_conflicts:
conflict.selected = id(conflict) in preselected_ids if conflict.action != ConflictAction.SAME else False
self._add_conflict_row(conflict)
for conflict in self._all_conflicts:
conflict.selected = conflict.action != ConflictAction.SAME
self._conflict_selection_state[self._conflict_key(conflict)] = conflict.selected
self.apply_all_new_button.setEnabled(new_count > 0 or self._graph_preview is not None)
self.apply_button.setEnabled(bool(conflicts) or self._graph_preview is not None)
self._update_selection_summaries()
self._refresh_conflict_table()
def populate_fetch_documents(self, documents: List[Dict[str, Any]]):
self._pull_preview_documents = list(documents)
@@ -836,7 +1025,14 @@ class SymGraphTabView(QWidget):
def _add_conflict_row(self, conflict: ConflictEntry):
row = self.conflict_table.rowCount()
self.conflict_table.insertRow(row)
self._set_checkbox_widget(self.conflict_table, row, conflict.selected)
key = self._conflict_key(conflict)
checked = self._conflict_selection_state.get(key, conflict.action != ConflictAction.SAME)
self._set_checkbox_widget(
self.conflict_table,
row,
checked,
on_change=lambda state, conflict_key=key: self._on_conflict_checkbox_changed(conflict_key, state),
)
addr_item = QTableWidgetItem(f"0x{conflict.address:x}")
addr_item.setData(Qt.UserRole, conflict.address)
@@ -896,13 +1092,15 @@ class SymGraphTabView(QWidget):
def clear_conflicts(self):
self.conflict_table.setRowCount(0)
self.fetch_documents_table.setRowCount(0)
self._all_conflicts = []
self._conflict_selection_state = {}
self._graph_preview = None
self._graph_stats = None
self._pull_preview_documents = []
self.summary_new_count.setText("New: 0")
self.summary_conflict_count.setText("Conflicts: 0")
self.summary_same_count.setText("Same: 0")
self.summary_selected_count.setText("Selected: 0")
self.summary_selected_count.setText("Selected: 0 symbols / 0 docs")
self.summary_document_count.setText("Documents: 0")
self.clear_graph_preview_data()
self.set_pull_status("", None)
@@ -910,13 +1108,7 @@ class SymGraphTabView(QWidget):
self.fetch_progress_label.setVisible(False)
def get_all_new_conflicts(self) -> List[ConflictEntry]:
results = []
for row in range(self.conflict_table.rowCount()):
item = self.conflict_table.item(row, 1)
conflict = item.data(Qt.UserRole + 1) if item else None
if conflict and conflict.action == ConflictAction.NEW:
results.append(conflict)
return results
return [conflict for conflict in self._all_conflicts if conflict.action == ConflictAction.NEW]
def get_selected_conflicts(self) -> List[ConflictEntry]:
selected = []
@@ -966,9 +1158,11 @@ class SymGraphTabView(QWidget):
self.query_button,
self.open_binary_button,
self.pull_preview_button,
self.fetch_reset_button,
self.apply_button,
self.apply_all_new_button,
self.push_preview_button,
self.push_reset_button,
self.push_button,
self.select_all_button,
self.deselect_all_button,
@@ -1017,6 +1211,8 @@ class SymGraphTabView(QWidget):
self.push_documents_count.setText("Documents: 0")
self.push_graph_nodes_label.setText("Graph Nodes: 0")
self.push_graph_edges_label.setText("Graph Edges: 0")
self.push_graph_summary_label.setText("No graph data included in this publish preview.")
self.set_push_status("Ready", success=None)
def set_push_preview(
self,
@@ -1036,6 +1232,13 @@ class SymGraphTabView(QWidget):
self.push_documents_count.setText(f"Documents: {len(self._push_preview_documents)}")
self.push_graph_nodes_label.setText(f"Graph Nodes: {self._push_graph_stats.get('nodes', 0):,}")
self.push_graph_edges_label.setText(f"Graph Edges: {self._push_graph_stats.get('edges', 0):,}")
if graph_data:
self.push_graph_summary_label.setText(
f"Publish preview includes {self._push_graph_stats.get('nodes', 0):,} graph nodes and "
f"{self._push_graph_stats.get('edges', 0):,} graph edges."
)
else:
self.push_graph_summary_label.setText("No graph data included in this publish preview.")
for index, symbol in enumerate(symbols):
row = self.push_preview_table.rowCount()