topicgate 1.1.0__py3-none-any.whl
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.
- topicgate/__init__.py +0 -0
- topicgate/__main__.py +5 -0
- topicgate/app/__init__.py +0 -0
- topicgate/app/app_dependencies.py +148 -0
- topicgate/app/broker_runtime_state.py +43 -0
- topicgate/app/models/__init__.py +1 -0
- topicgate/app/models/broker_snapshot.py +107 -0
- topicgate/app/models/mcp_setup.py +20 -0
- topicgate/app/services/__init__.py +0 -0
- topicgate/app/services/broker_profile_service.py +245 -0
- topicgate/app/services/broker_resolver.py +58 -0
- topicgate/app/services/broker_snapshot_service.py +403 -0
- topicgate/app/services/control_operation_service.py +158 -0
- topicgate/app/services/mcp_setup_service.py +192 -0
- topicgate/app/services/observation_cache_service.py +256 -0
- topicgate/app/services/observation_retention_policy_service.py +33 -0
- topicgate/app/services/persistence_lifecycle.py +25 -0
- topicgate/app/services/service_container.py +20 -0
- topicgate/app/services/service_item.py +13 -0
- topicgate/app/topicgate_runtime.py +496 -0
- topicgate/assets/icon.png +0 -0
- topicgate/assets/icon.svg +37 -0
- topicgate/core/__init__.py +0 -0
- topicgate/core/config/__init__.py +0 -0
- topicgate/core/config/app_config.py +7 -0
- topicgate/core/config/mqtt_config.py +10 -0
- topicgate/core/interfaces/__init__.py +0 -0
- topicgate/core/interfaces/broker_profile_store.py +26 -0
- topicgate/core/interfaces/mqtt_message_processor.py +12 -0
- topicgate/core/interfaces/observer_repository.py +82 -0
- topicgate/core/interfaces/topic_message_store.py +13 -0
- topicgate/core/models/__init__.py +0 -0
- topicgate/core/models/broker_profile.py +15 -0
- topicgate/core/models/broker_profile_identity.py +14 -0
- topicgate/core/models/broker_summary.py +14 -0
- topicgate/core/models/connection_status.py +9 -0
- topicgate/core/models/message_filter.py +9 -0
- topicgate/core/models/mqtt_message.py +13 -0
- topicgate/core/models/mqtt_observation.py +36 -0
- topicgate/core/models/mqtt_state.py +5 -0
- topicgate/core/models/observation_cache_administration.py +135 -0
- topicgate/core/models/observation_deletion_preview.py +39 -0
- topicgate/core/models/observation_retention_policy.py +65 -0
- topicgate/core/models/observer_model.py +22 -0
- topicgate/core/models/observer_workspace.py +13 -0
- topicgate/core/models/subscription.py +29 -0
- topicgate/core/models/topic_message.py +16 -0
- topicgate/core/mqtt_topics.py +46 -0
- topicgate/core/observer_limits.py +8 -0
- topicgate/core/payload_limits.py +6 -0
- topicgate/gui/__init__.py +0 -0
- topicgate/gui/app.py +97 -0
- topicgate/gui/broker_state_reader.py +29 -0
- topicgate/gui/components/about_dialog.py +100 -0
- topicgate/gui/components/add_subscription_dialog.py +59 -0
- topicgate/gui/components/application_header.py +137 -0
- topicgate/gui/components/broker_settings_dialog.py +223 -0
- topicgate/gui/components/connection_controls.py +123 -0
- topicgate/gui/components/connection_status.py +7 -0
- topicgate/gui/components/log_console.py +26 -0
- topicgate/gui/components/mcp_setup_dialog.py +198 -0
- topicgate/gui/components/observer_tree.py +434 -0
- topicgate/gui/components/onboarding_panel.py +74 -0
- topicgate/gui/components/publish_pane.py +60 -0
- topicgate/gui/components/snapshot_panel.py +343 -0
- topicgate/gui/components/stored_observations_dialog.py +611 -0
- topicgate/gui/components/subscription_settings.py +151 -0
- topicgate/gui/components/topic_details.py +51 -0
- topicgate/gui/components/topic_metadata.py +79 -0
- topicgate/gui/components/workspace_pane.py +30 -0
- topicgate/gui/gui.py +5 -0
- topicgate/gui/main_view_model.py +814 -0
- topicgate/gui/main_window.py +986 -0
- topicgate/gui/observer_state_reader.py +47 -0
- topicgate/gui/settings_migration.py +32 -0
- topicgate/gui/theme.py +61 -0
- topicgate/infrastructure/__init__.py +0 -0
- topicgate/infrastructure/credentials/__init__.py +1 -0
- topicgate/infrastructure/credentials/credential_store.py +10 -0
- topicgate/infrastructure/credentials/os_credential_store.py +20 -0
- topicgate/infrastructure/database/__init__.py +0 -0
- topicgate/infrastructure/database/base.py +5 -0
- topicgate/infrastructure/database/database_context.py +56 -0
- topicgate/infrastructure/database/mappers/broker_profile_mapper.py +42 -0
- topicgate/infrastructure/database/mappers/config_mapper.py +48 -0
- topicgate/infrastructure/database/mappers/mapper_helper.py +24 -0
- topicgate/infrastructure/database/mappers/observation_retention_policy_mapper.py +53 -0
- topicgate/infrastructure/database/mappers/observer_workspace_mapper.py +42 -0
- topicgate/infrastructure/database/mappers/subscription_mapper.py +38 -0
- topicgate/infrastructure/database/mappers/topic_message_mapper.py +37 -0
- topicgate/infrastructure/database/migrations.py +57 -0
- topicgate/infrastructure/database/models/__init__.py +25 -0
- topicgate/infrastructure/database/models/app_config_row.py +18 -0
- topicgate/infrastructure/database/models/broker_profile_row.py +35 -0
- topicgate/infrastructure/database/models/mqtt_config_row.py +18 -0
- topicgate/infrastructure/database/models/mqtt_message_row.py +33 -0
- topicgate/infrastructure/database/models/observation_retention_policy_row.py +59 -0
- topicgate/infrastructure/database/models/observer_workspace_row.py +39 -0
- topicgate/infrastructure/database/models/subscription_row.py +18 -0
- topicgate/infrastructure/mqtt/__init__.py +0 -0
- topicgate/infrastructure/mqtt/async_callback_bridge.py +105 -0
- topicgate/infrastructure/mqtt/callbacks/__init__.py +0 -0
- topicgate/infrastructure/mqtt/callbacks/basic_callbacks.py +61 -0
- topicgate/infrastructure/mqtt/callbacks/observer_repository_callbacks.py +79 -0
- topicgate/infrastructure/mqtt/mqtt_callbacks.py +53 -0
- topicgate/infrastructure/mqtt/mqtt_client.py +356 -0
- topicgate/infrastructure/mqtt/mqtt_gate.py +122 -0
- topicgate/infrastructure/repository/__init__.py +0 -0
- topicgate/infrastructure/repository/broker_config_repository.py +68 -0
- topicgate/infrastructure/repository/broker_repository.py +170 -0
- topicgate/infrastructure/repository/observation_retention_policy_repository.py +42 -0
- topicgate/infrastructure/repository/observer_mqtt_repository.py +335 -0
- topicgate/infrastructure/repository/subscription_repository.py +117 -0
- topicgate/infrastructure/repository/topic_message_repository.py +438 -0
- topicgate/mcp/__init__.py +0 -0
- topicgate/mcp/api/broker_api.py +48 -0
- topicgate/mcp/api/connection_api.py +83 -0
- topicgate/mcp/api/dashboard_api.py +790 -0
- topicgate/mcp/api/dashboard_snapshot.py +278 -0
- topicgate/mcp/api/mcp_api.py +18 -0
- topicgate/mcp/api/preview/dashboard_preview.py +6 -0
- topicgate/mcp/api/publish_api.py +62 -0
- topicgate/mcp/api/snapshot_api.py +78 -0
- topicgate/mcp/api/subscription_api.py +134 -0
- topicgate/mcp/api/topic_api.py +77 -0
- topicgate/mcp/capabilities.py +12 -0
- topicgate/mcp/instructions.py +3 -0
- topicgate/mcp/middleware/__init__.py +6 -0
- topicgate/mcp/middleware/error_handling_middleware.py +49 -0
- topicgate/mcp/middleware/logging_middleware.py +43 -0
- topicgate/mcp/models.py +28 -0
- topicgate/mcp/server.py +147 -0
- topicgate/paths.py +36 -0
- topicgate/presentation/__init__.py +39 -0
- topicgate/presentation/retention_presentation.py +187 -0
- topicgate/presentation/snapshot_presentation.py +130 -0
- topicgate/presentation/topic_presentation.py +482 -0
- topicgate/processors/__init__.py +0 -0
- topicgate/processors/observation_retention_processor.py +35 -0
- topicgate/processors/observer_model_mqtt_message_processor.py +79 -0
- topicgate/processors/observer_model_processor.py +223 -0
- topicgate/processors/subscription_manager.py +90 -0
- topicgate-1.1.0.dist-info/METADATA +222 -0
- topicgate-1.1.0.dist-info/RECORD +148 -0
- topicgate-1.1.0.dist-info/WHEEL +5 -0
- topicgate-1.1.0.dist-info/entry_points.txt +3 -0
- topicgate-1.1.0.dist-info/licenses/LICENCE +21 -0
- topicgate-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from PySide6.QtCore import Signal
|
|
2
|
+
from PySide6.QtGui import QGuiApplication
|
|
3
|
+
from PySide6.QtWidgets import (
|
|
4
|
+
QComboBox,
|
|
5
|
+
QDialog,
|
|
6
|
+
QDialogButtonBox,
|
|
7
|
+
QFormLayout,
|
|
8
|
+
QGroupBox,
|
|
9
|
+
QHBoxLayout,
|
|
10
|
+
QLabel,
|
|
11
|
+
QPlainTextEdit,
|
|
12
|
+
QPushButton,
|
|
13
|
+
QVBoxLayout,
|
|
14
|
+
QWidget,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from topicgate.gui.main_view_model import MainViewModel
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class McpSetupDialog(QDialog):
|
|
21
|
+
"""Configure MCP clients and diagnose the shared TopicGate installation."""
|
|
22
|
+
|
|
23
|
+
reconnect_observe_requested = Signal()
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
view_model: MainViewModel,
|
|
28
|
+
parent: QWidget | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__(parent)
|
|
31
|
+
self._view_model = view_model
|
|
32
|
+
self.setObjectName("mcpSetupDialog")
|
|
33
|
+
self.setWindowTitle("Configure TopicGate MCP")
|
|
34
|
+
self.resize(760, 700)
|
|
35
|
+
layout = QVBoxLayout(self)
|
|
36
|
+
|
|
37
|
+
introduction = QLabel(
|
|
38
|
+
"TopicGate Desktop is the setup and maintenance control center. "
|
|
39
|
+
"The MCP process is normally started by your host and shares this "
|
|
40
|
+
"database with the desktop. Read-only mode is recommended."
|
|
41
|
+
)
|
|
42
|
+
introduction.setWordWrap(True)
|
|
43
|
+
introduction.setAccessibleName("MCP setup introduction")
|
|
44
|
+
layout.addWidget(introduction)
|
|
45
|
+
|
|
46
|
+
information = self._view_model.mcp_setup_information
|
|
47
|
+
installation = QGroupBox("Installation")
|
|
48
|
+
install_form = QFormLayout(installation)
|
|
49
|
+
self._version = QLabel(information.version if information else "Development")
|
|
50
|
+
self._version.setObjectName("mcpTopicGateVersion")
|
|
51
|
+
executable = (
|
|
52
|
+
str(information.executable_path)
|
|
53
|
+
if information
|
|
54
|
+
else "Current Python environment"
|
|
55
|
+
)
|
|
56
|
+
self._executable = QLabel(executable)
|
|
57
|
+
self._executable.setObjectName("mcpExecutablePath")
|
|
58
|
+
install_form.addRow("TopicGate version", self._version)
|
|
59
|
+
install_form.addRow(
|
|
60
|
+
"Resolved executable",
|
|
61
|
+
self._path_row(self._executable, "copyMcpExecutablePath"),
|
|
62
|
+
)
|
|
63
|
+
if information is not None:
|
|
64
|
+
self._data_path = QLabel(str(information.data_path))
|
|
65
|
+
self._data_path.setObjectName("mcpDataPath")
|
|
66
|
+
self._database_path = QLabel(str(information.database_path))
|
|
67
|
+
self._database_path.setObjectName("mcpDatabasePath")
|
|
68
|
+
install_form.addRow(
|
|
69
|
+
"Application data",
|
|
70
|
+
self._path_row(self._data_path, "copyMcpDataPath"),
|
|
71
|
+
)
|
|
72
|
+
install_form.addRow(
|
|
73
|
+
"SQLite database",
|
|
74
|
+
self._path_row(self._database_path, "copyMcpDatabasePath"),
|
|
75
|
+
)
|
|
76
|
+
layout.addWidget(installation)
|
|
77
|
+
|
|
78
|
+
mode_row = QHBoxLayout()
|
|
79
|
+
mode_row.addWidget(QLabel("Configuration mode"))
|
|
80
|
+
self._mode = QComboBox()
|
|
81
|
+
self._mode.setObjectName("mcpConfigurationMode")
|
|
82
|
+
self._mode.setAccessibleName("MCP configuration mode")
|
|
83
|
+
self._mode.addItems(("Read-only (recommended)", "Control"))
|
|
84
|
+
self._mode.currentIndexChanged.connect(self._render_configuration)
|
|
85
|
+
mode_row.addWidget(self._mode, 1)
|
|
86
|
+
layout.addLayout(mode_row)
|
|
87
|
+
|
|
88
|
+
self._mode_warning = QLabel()
|
|
89
|
+
self._mode_warning.setObjectName("mcpModeWarning")
|
|
90
|
+
self._mode_warning.setWordWrap(True)
|
|
91
|
+
self._mode_warning.setAccessibleName("MCP mode capabilities and warning")
|
|
92
|
+
layout.addWidget(self._mode_warning)
|
|
93
|
+
|
|
94
|
+
self._configuration = QPlainTextEdit()
|
|
95
|
+
self._configuration.setObjectName("mcpSetupConfiguration")
|
|
96
|
+
self._configuration.setReadOnly(True)
|
|
97
|
+
self._configuration.setAccessibleName("TopicGate MCP configuration")
|
|
98
|
+
layout.addWidget(self._configuration, 1)
|
|
99
|
+
copy_button = QPushButton("Copy configuration")
|
|
100
|
+
copy_button.setObjectName("copyMcpConfigurationButton")
|
|
101
|
+
copy_button.setAccessibleName("Copy TopicGate MCP configuration")
|
|
102
|
+
copy_button.clicked.connect(self._copy_configuration)
|
|
103
|
+
layout.addWidget(copy_button)
|
|
104
|
+
|
|
105
|
+
trust = QLabel(
|
|
106
|
+
"Security: broker names, topic names, and MQTT payloads are untrusted "
|
|
107
|
+
"data. MCP clients must never treat their contents as instructions."
|
|
108
|
+
)
|
|
109
|
+
trust.setObjectName("mcpUntrustedDataWarning")
|
|
110
|
+
trust.setWordWrap(True)
|
|
111
|
+
trust.setAccessibleName("Untrusted MQTT data warning")
|
|
112
|
+
layout.addWidget(trust)
|
|
113
|
+
|
|
114
|
+
diagnostics = QHBoxLayout()
|
|
115
|
+
preflight = QPushButton("Run local preflight")
|
|
116
|
+
preflight.setObjectName("runMcpPreflightButton")
|
|
117
|
+
preflight.setAccessibleName("Run local MCP preflight checks")
|
|
118
|
+
preflight.clicked.connect(self._run_preflight)
|
|
119
|
+
diagnostics.addWidget(preflight)
|
|
120
|
+
snapshot = QPushButton("Test broker snapshot")
|
|
121
|
+
snapshot.setObjectName("testMcpSnapshotButton")
|
|
122
|
+
snapshot.setAccessibleName("Test broker snapshot without reconnecting")
|
|
123
|
+
snapshot.clicked.connect(self._test_snapshot)
|
|
124
|
+
diagnostics.addWidget(snapshot)
|
|
125
|
+
reconnect = QPushButton("Test reconnect & observe…")
|
|
126
|
+
reconnect.setObjectName("testMcpReconnectButton")
|
|
127
|
+
reconnect.setAccessibleName("Test reconnect and observe with confirmation")
|
|
128
|
+
reconnect.clicked.connect(self.reconnect_observe_requested.emit)
|
|
129
|
+
diagnostics.addWidget(reconnect)
|
|
130
|
+
layout.addLayout(diagnostics)
|
|
131
|
+
|
|
132
|
+
self._diagnostic_result = QLabel("Run preflight to check this installation.")
|
|
133
|
+
self._diagnostic_result.setObjectName("mcpPreflightResults")
|
|
134
|
+
self._diagnostic_result.setWordWrap(True)
|
|
135
|
+
self._diagnostic_result.setAccessibleName("MCP preflight results")
|
|
136
|
+
layout.addWidget(self._diagnostic_result)
|
|
137
|
+
|
|
138
|
+
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
|
139
|
+
buttons.rejected.connect(self.accept)
|
|
140
|
+
layout.addWidget(buttons)
|
|
141
|
+
self._render_configuration()
|
|
142
|
+
|
|
143
|
+
def _path_row(self, label: QLabel, button_name: str) -> QWidget:
|
|
144
|
+
row = QWidget()
|
|
145
|
+
layout = QHBoxLayout(row)
|
|
146
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
147
|
+
layout.addWidget(label, 1)
|
|
148
|
+
copy = QPushButton("Copy")
|
|
149
|
+
copy.setObjectName(button_name)
|
|
150
|
+
copy.setAccessibleName(f"Copy {label.objectName()}")
|
|
151
|
+
copy.clicked.connect(
|
|
152
|
+
lambda _checked=False, value=label.text(): (
|
|
153
|
+
QGuiApplication.clipboard().setText(value)
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
layout.addWidget(copy)
|
|
157
|
+
return row
|
|
158
|
+
|
|
159
|
+
def _render_configuration(self) -> None:
|
|
160
|
+
control = self._mode.currentIndex() == 1
|
|
161
|
+
mode = "control" if control else "read-only"
|
|
162
|
+
self._configuration.setPlainText(self._view_model.mcp_configuration(mode))
|
|
163
|
+
if control:
|
|
164
|
+
self._mode_warning.setText(
|
|
165
|
+
"Control mode exposes connection changes, broker activation, "
|
|
166
|
+
"subscription changes, observation refresh, and publishing. "
|
|
167
|
+
"It uses the shared control lease and can be blocked by desktop work."
|
|
168
|
+
)
|
|
169
|
+
self._mode_warning.setStyleSheet("color: #991b1b; font-weight: 600;")
|
|
170
|
+
else:
|
|
171
|
+
self._mode_warning.setText(
|
|
172
|
+
"Read-only exposes broker, connection, subscription, topic, and "
|
|
173
|
+
"snapshot inspection. It does not reconnect, subscribe, or publish."
|
|
174
|
+
)
|
|
175
|
+
self._mode_warning.setStyleSheet("")
|
|
176
|
+
|
|
177
|
+
def _run_preflight(self) -> None:
|
|
178
|
+
checks = self._view_model.run_mcp_preflight()
|
|
179
|
+
self._diagnostic_result.setText(
|
|
180
|
+
"\n".join(
|
|
181
|
+
f"{check.status.upper()}: {check.name} — {check.detail}"
|
|
182
|
+
for check in checks
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def _test_snapshot(self) -> None:
|
|
187
|
+
try:
|
|
188
|
+
health = self._view_model.test_broker_snapshot()
|
|
189
|
+
except Exception as error:
|
|
190
|
+
self._diagnostic_result.setText(f"FAIL: Broker snapshot — {error}")
|
|
191
|
+
return
|
|
192
|
+
self._diagnostic_result.setText(
|
|
193
|
+
"PASS: Broker snapshot — "
|
|
194
|
+
f"{health.returned_count} returned; {health.completeness_status}."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def _copy_configuration(self) -> None:
|
|
198
|
+
QGuiApplication.clipboard().setText(self._configuration.toPlainText())
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
from uuid import UUID
|
|
2
|
+
|
|
3
|
+
from PySide6.QtCore import QModelIndex, QSize, QSortFilterProxyModel, Qt, Signal
|
|
4
|
+
from PySide6.QtGui import QStandardItem, QStandardItemModel
|
|
5
|
+
from PySide6.QtWidgets import (
|
|
6
|
+
QHBoxLayout,
|
|
7
|
+
QFrame,
|
|
8
|
+
QHeaderView,
|
|
9
|
+
QLabel,
|
|
10
|
+
QLineEdit,
|
|
11
|
+
QMenu,
|
|
12
|
+
QScrollArea,
|
|
13
|
+
QStyle,
|
|
14
|
+
QToolButton,
|
|
15
|
+
QTreeView,
|
|
16
|
+
QWidget,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from topicgate.core.models.broker_summary import BrokerSummary
|
|
20
|
+
from topicgate.core.models.subscription import Subscription
|
|
21
|
+
from topicgate.gui.components.workspace_pane import WorkspacePane
|
|
22
|
+
from topicgate.gui.components.snapshot_panel import SnapshotPanel
|
|
23
|
+
from topicgate.presentation.topic_presentation import TopicTreeNode
|
|
24
|
+
|
|
25
|
+
TOPIC_ROLE = Qt.ItemDataRole.UserRole + 1
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ObserverTreePane(WorkspacePane):
|
|
29
|
+
"""Searchable tree of configured and dynamically observed MQTT topics."""
|
|
30
|
+
|
|
31
|
+
topic_selected = Signal(str)
|
|
32
|
+
add_filter_requested = Signal()
|
|
33
|
+
remove_filter_requested = Signal(object)
|
|
34
|
+
broker_profile_selected = Signal(object)
|
|
35
|
+
add_broker_profile_requested = Signal()
|
|
36
|
+
edit_broker_profile_requested = Signal(object)
|
|
37
|
+
delete_broker_profile_requested = Signal()
|
|
38
|
+
snapshot_apply_requested = Signal(object)
|
|
39
|
+
snapshot_reset_requested = Signal()
|
|
40
|
+
reconnect_observe_requested = Signal(object)
|
|
41
|
+
empty_state_action_requested = Signal(str)
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
super().__init__("Observer Tree")
|
|
45
|
+
self._items: dict[str, QStandardItem] = {}
|
|
46
|
+
|
|
47
|
+
controls = QHBoxLayout()
|
|
48
|
+
self._search_edit = QLineEdit()
|
|
49
|
+
self._search_edit.setPlaceholderText("Search topics...")
|
|
50
|
+
self._search_edit.setAccessibleName("Search observed topics")
|
|
51
|
+
self._search_edit.setClearButtonEnabled(True)
|
|
52
|
+
controls.addWidget(self._search_edit, 1)
|
|
53
|
+
|
|
54
|
+
add_button = QToolButton()
|
|
55
|
+
add_button.setText("+ Filter")
|
|
56
|
+
add_button.setToolTip("Add an MQTT subscription filter")
|
|
57
|
+
add_button.setAccessibleName("Add MQTT subscription filter")
|
|
58
|
+
add_button.clicked.connect(self.add_filter_requested)
|
|
59
|
+
controls.addWidget(add_button)
|
|
60
|
+
|
|
61
|
+
self._broker_profile_button = QToolButton()
|
|
62
|
+
self._broker_profile_button.setObjectName("brokerProfileButton")
|
|
63
|
+
self._broker_profile_button.setMinimumWidth(126)
|
|
64
|
+
self._broker_profile_button.setPopupMode(
|
|
65
|
+
QToolButton.ToolButtonPopupMode.InstantPopup
|
|
66
|
+
)
|
|
67
|
+
self._broker_profile_menu = QMenu(self._broker_profile_button)
|
|
68
|
+
self._broker_profile_button.setMenu(self._broker_profile_menu)
|
|
69
|
+
controls.addWidget(self._broker_profile_button)
|
|
70
|
+
self.content_layout.addLayout(controls)
|
|
71
|
+
|
|
72
|
+
self._model = QStandardItemModel(self)
|
|
73
|
+
self._model.setHorizontalHeaderLabels(["Topic", "", "State"])
|
|
74
|
+
self._proxy = QSortFilterProxyModel(self)
|
|
75
|
+
self._proxy.setSourceModel(self._model)
|
|
76
|
+
self._proxy.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
|
77
|
+
self._proxy.setRecursiveFilteringEnabled(True)
|
|
78
|
+
self._proxy.setFilterKeyColumn(0)
|
|
79
|
+
|
|
80
|
+
self._tree = QTreeView()
|
|
81
|
+
self._tree.setObjectName("observerTree")
|
|
82
|
+
self._tree.setModel(self._proxy)
|
|
83
|
+
self._tree.setAlternatingRowColors(True)
|
|
84
|
+
self._tree.setUniformRowHeights(True)
|
|
85
|
+
self._tree.header().setSectionResizeMode(
|
|
86
|
+
0,
|
|
87
|
+
QHeaderView.ResizeMode.Stretch,
|
|
88
|
+
)
|
|
89
|
+
self._tree.header().setStretchLastSection(False)
|
|
90
|
+
self._tree.header().setSectionResizeMode(
|
|
91
|
+
1,
|
|
92
|
+
QHeaderView.ResizeMode.Fixed,
|
|
93
|
+
)
|
|
94
|
+
self._tree.header().resizeSection(1, 34)
|
|
95
|
+
self._tree.header().setSectionResizeMode(
|
|
96
|
+
2,
|
|
97
|
+
QHeaderView.ResizeMode.Fixed,
|
|
98
|
+
)
|
|
99
|
+
self._tree.header().resizeSection(2, 136)
|
|
100
|
+
self._tree.selectionModel().currentChanged.connect(
|
|
101
|
+
self._selection_changed
|
|
102
|
+
)
|
|
103
|
+
self._search_edit.textChanged.connect(self._proxy.setFilterFixedString)
|
|
104
|
+
self.content_layout.addWidget(self._tree, 1)
|
|
105
|
+
self._empty_state = QFrame()
|
|
106
|
+
self._empty_state.setObjectName("observerEmptyState")
|
|
107
|
+
self._empty_state.setFrameShape(QFrame.Shape.StyledPanel)
|
|
108
|
+
empty_layout = QHBoxLayout(self._empty_state)
|
|
109
|
+
self._empty_state_text = QLabel()
|
|
110
|
+
self._empty_state_text.setObjectName("observerEmptyStateText")
|
|
111
|
+
self._empty_state_text.setWordWrap(True)
|
|
112
|
+
empty_layout.addWidget(self._empty_state_text, 1)
|
|
113
|
+
self._empty_state_action = QToolButton()
|
|
114
|
+
self._empty_state_action.setObjectName("observerEmptyStateAction")
|
|
115
|
+
self._empty_state_action.clicked.connect(
|
|
116
|
+
lambda: self.empty_state_action_requested.emit(
|
|
117
|
+
str(self._empty_state_action.property("action") or "")
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
empty_layout.addWidget(self._empty_state_action)
|
|
121
|
+
self.content_layout.addWidget(self._empty_state)
|
|
122
|
+
self.snapshot_panel = SnapshotPanel()
|
|
123
|
+
self.snapshot_panel.apply_requested.connect(
|
|
124
|
+
self.snapshot_apply_requested.emit
|
|
125
|
+
)
|
|
126
|
+
self.snapshot_panel.reset_requested.connect(
|
|
127
|
+
self.snapshot_reset_requested.emit
|
|
128
|
+
)
|
|
129
|
+
self.snapshot_panel.reconnect_observe_requested.connect(
|
|
130
|
+
self.reconnect_observe_requested.emit
|
|
131
|
+
)
|
|
132
|
+
snapshot_scroll = QScrollArea()
|
|
133
|
+
snapshot_scroll.setObjectName("snapshotPanelScrollArea")
|
|
134
|
+
snapshot_scroll.setWidgetResizable(True)
|
|
135
|
+
snapshot_scroll.setFrameShape(QScrollArea.Shape.NoFrame)
|
|
136
|
+
snapshot_scroll.setHorizontalScrollBarPolicy(
|
|
137
|
+
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
|
138
|
+
)
|
|
139
|
+
snapshot_scroll.setMinimumHeight(68)
|
|
140
|
+
snapshot_scroll.setMaximumHeight(68)
|
|
141
|
+
snapshot_scroll.setWidget(self.snapshot_panel)
|
|
142
|
+
|
|
143
|
+
def resize_snapshot(expanded: bool) -> None:
|
|
144
|
+
snapshot_scroll.setMinimumHeight(300 if expanded else 68)
|
|
145
|
+
snapshot_scroll.setMaximumHeight(360 if expanded else 68)
|
|
146
|
+
|
|
147
|
+
self.snapshot_panel.expansion_changed.connect(resize_snapshot)
|
|
148
|
+
self.content_layout.addWidget(snapshot_scroll)
|
|
149
|
+
|
|
150
|
+
def render(
|
|
151
|
+
self,
|
|
152
|
+
topic_paths: list[str],
|
|
153
|
+
selected_topic: str,
|
|
154
|
+
subscriptions: tuple[Subscription, ...] = (),
|
|
155
|
+
) -> None:
|
|
156
|
+
expanded_paths = {
|
|
157
|
+
path
|
|
158
|
+
for path, item in self._items.items()
|
|
159
|
+
if self._tree.isExpanded(self._proxy.mapFromSource(item.index()))
|
|
160
|
+
}
|
|
161
|
+
self._model.removeRows(0, self._model.rowCount())
|
|
162
|
+
self._items.clear()
|
|
163
|
+
|
|
164
|
+
for topic in topic_paths:
|
|
165
|
+
self._add_topic(topic)
|
|
166
|
+
|
|
167
|
+
for subscription in subscriptions:
|
|
168
|
+
self._add_remove_button(subscription)
|
|
169
|
+
|
|
170
|
+
if expanded_paths:
|
|
171
|
+
self._restore_expanded_paths(expanded_paths)
|
|
172
|
+
else:
|
|
173
|
+
self._tree.expandToDepth(1)
|
|
174
|
+
self.select_topic(selected_topic)
|
|
175
|
+
|
|
176
|
+
def render_empty_state(
|
|
177
|
+
self,
|
|
178
|
+
connection_status: str,
|
|
179
|
+
subscriptions: tuple[Subscription, ...],
|
|
180
|
+
query_is_filtered: bool,
|
|
181
|
+
has_cached_values: bool,
|
|
182
|
+
has_topics: bool,
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Explain why the workspace has no immediately useful live values."""
|
|
185
|
+
if has_topics and not has_cached_values:
|
|
186
|
+
self._empty_state.setVisible(False)
|
|
187
|
+
return
|
|
188
|
+
if not subscriptions:
|
|
189
|
+
message, action, label = (
|
|
190
|
+
"No subscriptions are configured. Add a filter before TopicGate can observe values.",
|
|
191
|
+
"add-filter",
|
|
192
|
+
"Add filter",
|
|
193
|
+
)
|
|
194
|
+
elif connection_status == "disconnected":
|
|
195
|
+
message, action, label = (
|
|
196
|
+
"The active broker is disconnected. Cached values may be old until you reconnect.",
|
|
197
|
+
"connect",
|
|
198
|
+
"Connect",
|
|
199
|
+
)
|
|
200
|
+
elif query_is_filtered and not has_topics:
|
|
201
|
+
message, action, label = (
|
|
202
|
+
"No values match the current snapshot filters. Clear filters or capture a fresh snapshot.",
|
|
203
|
+
"clear-filters",
|
|
204
|
+
"Clear filters",
|
|
205
|
+
)
|
|
206
|
+
elif has_cached_values:
|
|
207
|
+
message, action, label = (
|
|
208
|
+
"Only persisted values are available. Their source and age are shown in Details; reconnect to collect fresh values.",
|
|
209
|
+
"observe",
|
|
210
|
+
"Reconnect & observe",
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
message, action, label = (
|
|
214
|
+
"No values have been observed yet. Capture a fresh snapshot after publishers send messages.",
|
|
215
|
+
"observe",
|
|
216
|
+
"Reconnect & observe",
|
|
217
|
+
)
|
|
218
|
+
self._empty_state_text.setText(message)
|
|
219
|
+
self._empty_state_text.setAccessibleName(message)
|
|
220
|
+
self._empty_state_action.setText(label)
|
|
221
|
+
self._empty_state_action.setAccessibleName(label)
|
|
222
|
+
self._empty_state_action.setProperty("action", action)
|
|
223
|
+
self._empty_state.setVisible(True)
|
|
224
|
+
|
|
225
|
+
def render_tree(
|
|
226
|
+
self,
|
|
227
|
+
nodes: tuple[TopicTreeNode, ...],
|
|
228
|
+
selected_topic: str,
|
|
229
|
+
subscriptions: tuple[Subscription, ...] = (),
|
|
230
|
+
) -> None:
|
|
231
|
+
paths: list[str] = []
|
|
232
|
+
|
|
233
|
+
def append(items: tuple[TopicTreeNode, ...]) -> None:
|
|
234
|
+
for item in items:
|
|
235
|
+
paths.append(item.path)
|
|
236
|
+
append(item.children)
|
|
237
|
+
|
|
238
|
+
append(nodes)
|
|
239
|
+
self.render(paths, selected_topic, subscriptions)
|
|
240
|
+
|
|
241
|
+
def add_badges(items: tuple[TopicTreeNode, ...]) -> None:
|
|
242
|
+
for node in items:
|
|
243
|
+
if node.badges:
|
|
244
|
+
self._set_badges(node)
|
|
245
|
+
add_badges(node.children)
|
|
246
|
+
|
|
247
|
+
add_badges(nodes)
|
|
248
|
+
|
|
249
|
+
def select_topic(self, topic: str) -> None:
|
|
250
|
+
item = self._items.get(topic)
|
|
251
|
+
if item is None:
|
|
252
|
+
return
|
|
253
|
+
proxy_index = self._proxy.mapFromSource(item.index())
|
|
254
|
+
if proxy_index.isValid():
|
|
255
|
+
self._tree.setCurrentIndex(proxy_index)
|
|
256
|
+
self._tree.scrollTo(proxy_index)
|
|
257
|
+
|
|
258
|
+
def expand_all(self) -> None:
|
|
259
|
+
self._tree.expandAll()
|
|
260
|
+
|
|
261
|
+
def collapse_all(self) -> None:
|
|
262
|
+
self._tree.collapseAll()
|
|
263
|
+
|
|
264
|
+
def focus_search(self) -> None:
|
|
265
|
+
self._search_edit.setFocus(Qt.FocusReason.ShortcutFocusReason)
|
|
266
|
+
|
|
267
|
+
def render_broker_profiles(
|
|
268
|
+
self,
|
|
269
|
+
profiles: tuple[BrokerSummary, ...],
|
|
270
|
+
active_profile_id: UUID,
|
|
271
|
+
) -> None:
|
|
272
|
+
"""Render a quick-switch menu for the available broker profiles."""
|
|
273
|
+
self._broker_profile_menu.clear()
|
|
274
|
+
active_profile = next(
|
|
275
|
+
profile for profile in profiles if profile.id == active_profile_id
|
|
276
|
+
)
|
|
277
|
+
self._broker_profile_button.setText(active_profile.name)
|
|
278
|
+
self._broker_profile_button.setToolTip(
|
|
279
|
+
f"Switch broker profile (current: {active_profile.name})"
|
|
280
|
+
)
|
|
281
|
+
self._broker_profile_button.setAccessibleName("Switch broker profile")
|
|
282
|
+
for profile in profiles:
|
|
283
|
+
action = self._broker_profile_menu.addAction(profile.name)
|
|
284
|
+
action.setCheckable(True)
|
|
285
|
+
action.setChecked(profile.id == active_profile_id)
|
|
286
|
+
action.setEnabled(profile.id != active_profile_id)
|
|
287
|
+
action.triggered.connect(
|
|
288
|
+
lambda _checked=False, profile_id=profile.id: (
|
|
289
|
+
self.broker_profile_selected.emit(profile_id)
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
self._broker_profile_menu.addSeparator()
|
|
293
|
+
add_action = self._broker_profile_menu.addAction("Add profile...")
|
|
294
|
+
add_action.setObjectName("addBrokerProfileAction")
|
|
295
|
+
add_action.triggered.connect(self.add_broker_profile_requested.emit)
|
|
296
|
+
edit_menu = self._broker_profile_menu.addMenu("Edit profile...")
|
|
297
|
+
edit_menu.menuAction().setObjectName("editBrokerProfileAction")
|
|
298
|
+
for profile in profiles:
|
|
299
|
+
edit_action = edit_menu.addAction(profile.name)
|
|
300
|
+
edit_action.triggered.connect(
|
|
301
|
+
lambda _checked=False, profile_id=profile.id: (
|
|
302
|
+
self.edit_broker_profile_requested.emit(profile_id)
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
delete_action = self._broker_profile_menu.addAction(
|
|
306
|
+
"Delete current profile..."
|
|
307
|
+
)
|
|
308
|
+
delete_action.setObjectName("deleteBrokerProfileAction")
|
|
309
|
+
delete_action.setEnabled(len(profiles) > 1)
|
|
310
|
+
delete_action.triggered.connect(self.delete_broker_profile_requested.emit)
|
|
311
|
+
|
|
312
|
+
def set_profile_switching(self, switching: bool) -> None:
|
|
313
|
+
"""Prevent duplicate profile switches while the broker reconnects."""
|
|
314
|
+
self._broker_profile_button.setEnabled(not switching)
|
|
315
|
+
action = str(self._empty_state_action.property("action") or "")
|
|
316
|
+
if action in {"connect", "observe"}:
|
|
317
|
+
self._empty_state_action.setEnabled(not switching)
|
|
318
|
+
|
|
319
|
+
def _add_topic(self, topic: str) -> None:
|
|
320
|
+
parent = self._model.invisibleRootItem()
|
|
321
|
+
partial_path: list[str] = []
|
|
322
|
+
for segment in topic.split("/"):
|
|
323
|
+
partial_path.append(segment)
|
|
324
|
+
path = "/".join(partial_path)
|
|
325
|
+
item = self._items.get(path)
|
|
326
|
+
if item is None:
|
|
327
|
+
item = QStandardItem(segment or "/")
|
|
328
|
+
item.setEditable(False)
|
|
329
|
+
item.setData(path, TOPIC_ROLE)
|
|
330
|
+
state_item = QStandardItem()
|
|
331
|
+
state_item.setEditable(False)
|
|
332
|
+
action_item = QStandardItem()
|
|
333
|
+
action_item.setEditable(False)
|
|
334
|
+
parent.appendRow([item, state_item, action_item])
|
|
335
|
+
self._items[path] = item
|
|
336
|
+
parent = item
|
|
337
|
+
|
|
338
|
+
def _add_remove_button(self, subscription: Subscription) -> None:
|
|
339
|
+
item = self._items.get(subscription.topic_filter)
|
|
340
|
+
if item is None:
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
action_widget = QWidget()
|
|
344
|
+
action_layout = QHBoxLayout(action_widget)
|
|
345
|
+
action_layout.setContentsMargins(5, 0, 5, 0)
|
|
346
|
+
action_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
347
|
+
|
|
348
|
+
button = QToolButton(action_widget)
|
|
349
|
+
button.setObjectName("removeSubscriptionButton")
|
|
350
|
+
button.setFixedSize(24, 18)
|
|
351
|
+
button.setIconSize(QSize(12, 12))
|
|
352
|
+
button.setIcon(
|
|
353
|
+
self.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon)
|
|
354
|
+
)
|
|
355
|
+
button.setStyleSheet(
|
|
356
|
+
"QToolButton {"
|
|
357
|
+
" background-color: #7f1d1d;"
|
|
358
|
+
" border: 1px solid #b91c1c;"
|
|
359
|
+
" border-radius: 4px;"
|
|
360
|
+
" padding: 0;"
|
|
361
|
+
"}"
|
|
362
|
+
"QToolButton:hover {"
|
|
363
|
+
" background-color: #b91c1c;"
|
|
364
|
+
" border-color: #ef4444;"
|
|
365
|
+
"}"
|
|
366
|
+
"QToolButton:pressed {"
|
|
367
|
+
" background-color: #450a0a;"
|
|
368
|
+
" border-color: #991b1b;"
|
|
369
|
+
"}"
|
|
370
|
+
)
|
|
371
|
+
button.setToolTip(f"Remove subscription {subscription.topic_filter}")
|
|
372
|
+
button.setAccessibleName(
|
|
373
|
+
f"Remove subscription {subscription.topic_filter}"
|
|
374
|
+
)
|
|
375
|
+
button.clicked.connect(
|
|
376
|
+
lambda _checked=False, subscription=subscription: (
|
|
377
|
+
self.remove_filter_requested.emit(subscription)
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
action_layout.addWidget(button)
|
|
381
|
+
action_index = item.index().siblingAtColumn(1)
|
|
382
|
+
self._tree.setIndexWidget(
|
|
383
|
+
self._proxy.mapFromSource(action_index),
|
|
384
|
+
action_widget,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def _set_badges(self, node: TopicTreeNode) -> None:
|
|
388
|
+
item = self._items.get(node.path)
|
|
389
|
+
if item is None:
|
|
390
|
+
return
|
|
391
|
+
widget = QWidget()
|
|
392
|
+
layout = QHBoxLayout(widget)
|
|
393
|
+
layout.setContentsMargins(2, 0, 2, 0)
|
|
394
|
+
layout.setSpacing(3)
|
|
395
|
+
colors = {
|
|
396
|
+
"success": ("#dcfce7", "#166534"),
|
|
397
|
+
"info": ("#dbeafe", "#1e40af"),
|
|
398
|
+
"warning": ("#fef3c7", "#92400e"),
|
|
399
|
+
"neutral": ("#e5e7eb", "#374151"),
|
|
400
|
+
}
|
|
401
|
+
for badge in node.badges:
|
|
402
|
+
label = QLabel(badge.label)
|
|
403
|
+
label.setObjectName("topicStateBadge")
|
|
404
|
+
label.setProperty("badgeKey", badge.key)
|
|
405
|
+
background, foreground = colors[badge.tone]
|
|
406
|
+
label.setStyleSheet(
|
|
407
|
+
f"background: {background}; color: {foreground}; "
|
|
408
|
+
"border-radius: 5px; padding: 1px 4px; font-size: 10px;"
|
|
409
|
+
)
|
|
410
|
+
layout.addWidget(label)
|
|
411
|
+
layout.addStretch(1)
|
|
412
|
+
state_index = item.index().siblingAtColumn(2)
|
|
413
|
+
self._tree.setIndexWidget(self._proxy.mapFromSource(state_index), widget)
|
|
414
|
+
|
|
415
|
+
def _restore_expanded_paths(self, expanded_paths: set[str]) -> None:
|
|
416
|
+
for path in expanded_paths:
|
|
417
|
+
item = self._items.get(path)
|
|
418
|
+
if item is not None:
|
|
419
|
+
self._tree.setExpanded(
|
|
420
|
+
self._proxy.mapFromSource(item.index()),
|
|
421
|
+
True,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
def _selection_changed(
|
|
425
|
+
self,
|
|
426
|
+
current: QModelIndex,
|
|
427
|
+
_previous: QModelIndex,
|
|
428
|
+
) -> None:
|
|
429
|
+
source_index = self._proxy.mapToSource(current)
|
|
430
|
+
topic = self._model.data(source_index, TOPIC_ROLE) or ""
|
|
431
|
+
self.topic_selected.emit(str(topic))
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
TopicNavigationPane = ObserverTreePane
|