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,74 @@
|
|
|
1
|
+
from PySide6.QtCore import Qt, Signal
|
|
2
|
+
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class OnboardingPanel(QFrame):
|
|
6
|
+
"""Guide a first-time desktop user through the safe setup sequence."""
|
|
7
|
+
|
|
8
|
+
configure_broker_requested = Signal()
|
|
9
|
+
test_connection_requested = Signal()
|
|
10
|
+
add_subscription_requested = Signal()
|
|
11
|
+
observe_requested = Signal()
|
|
12
|
+
configure_mcp_requested = Signal()
|
|
13
|
+
dismissed = Signal()
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
super().__init__()
|
|
17
|
+
self.setObjectName("firstRunChecklist")
|
|
18
|
+
self.setFrameShape(QFrame.Shape.StyledPanel)
|
|
19
|
+
self.setStyleSheet(
|
|
20
|
+
"#firstRunChecklist { background: #eff6ff; border: 1px solid #93c5fd; "
|
|
21
|
+
"border-radius: 6px; color: #172554; }"
|
|
22
|
+
"#firstRunChecklist QPushButton { color: #172554; background: #ffffff; "
|
|
23
|
+
"border: 1px solid #60a5fa; border-radius: 4px; padding: 4px 8px; }"
|
|
24
|
+
"#firstRunChecklist QPushButton:hover { background: #dbeafe; }"
|
|
25
|
+
)
|
|
26
|
+
layout = QVBoxLayout(self)
|
|
27
|
+
layout.setContentsMargins(12, 10, 12, 10)
|
|
28
|
+
title_row = QHBoxLayout()
|
|
29
|
+
title = QLabel("Get started with TopicGate")
|
|
30
|
+
title.setObjectName("firstRunChecklistTitle")
|
|
31
|
+
title.setStyleSheet("font-weight: 600; color: #172554;")
|
|
32
|
+
title_row.addWidget(title)
|
|
33
|
+
title_row.addStretch(1)
|
|
34
|
+
dismiss = QPushButton("Dismiss")
|
|
35
|
+
dismiss.setObjectName("dismissFirstRunChecklistButton")
|
|
36
|
+
dismiss.setAccessibleName("Dismiss first-run checklist")
|
|
37
|
+
dismiss.clicked.connect(self.dismissed.emit)
|
|
38
|
+
title_row.addWidget(dismiss)
|
|
39
|
+
layout.addLayout(title_row)
|
|
40
|
+
self._rows: dict[str, QLabel] = {}
|
|
41
|
+
self._buttons: dict[str, QPushButton] = {}
|
|
42
|
+
for key, text, button_text, signal in (
|
|
43
|
+
("broker", "Configure a broker profile", "Configure", self.configure_broker_requested),
|
|
44
|
+
("connection", "Test the broker connection", "Connect", self.test_connection_requested),
|
|
45
|
+
("subscription", "Add a subscription filter", "Add filter", self.add_subscription_requested),
|
|
46
|
+
("observe", "Observe values with a fresh snapshot", "Observe", self.observe_requested),
|
|
47
|
+
("mcp", "Configure the MCP integration", "MCP setup", self.configure_mcp_requested),
|
|
48
|
+
):
|
|
49
|
+
row = QHBoxLayout()
|
|
50
|
+
label = QLabel()
|
|
51
|
+
label.setObjectName(f"firstRun{key.title()}Status")
|
|
52
|
+
label.setTextFormat(Qt.TextFormat.PlainText)
|
|
53
|
+
label.setStyleSheet("color: #172554;")
|
|
54
|
+
label.setProperty("checklistText", text)
|
|
55
|
+
row.addWidget(label, 1)
|
|
56
|
+
button = QPushButton(button_text)
|
|
57
|
+
button.setObjectName(f"firstRun{key.title()}Button")
|
|
58
|
+
button.setAccessibleName(text)
|
|
59
|
+
button.clicked.connect(signal.emit)
|
|
60
|
+
row.addWidget(button)
|
|
61
|
+
layout.addLayout(row)
|
|
62
|
+
self._rows[key] = label
|
|
63
|
+
self._buttons[key] = button
|
|
64
|
+
self.setAccessibleName("First-run checklist")
|
|
65
|
+
|
|
66
|
+
def render(self, completed: dict[str, bool], busy: bool = False) -> None:
|
|
67
|
+
for key, label in self._rows.items():
|
|
68
|
+
done = completed.get(key, False)
|
|
69
|
+
text = str(label.property("checklistText"))
|
|
70
|
+
label.setText(("Done: " if done else "Next: ") + text)
|
|
71
|
+
# Check the visible text is mirrored in accessibility state.
|
|
72
|
+
label.setAccessibleName(label.text())
|
|
73
|
+
self._buttons[key].setEnabled(not done and not busy)
|
|
74
|
+
self.setVisible(not all(completed.values()))
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from PySide6.QtCore import Signal
|
|
2
|
+
from PySide6.QtWidgets import QComboBox, QFormLayout, QLineEdit, QPlainTextEdit, QPushButton
|
|
3
|
+
|
|
4
|
+
from topicgate.gui.components.workspace_pane import WorkspacePane
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PublishPane(WorkspacePane):
|
|
8
|
+
publish_requested = Signal(str, str, str)
|
|
9
|
+
|
|
10
|
+
def __init__(self) -> None:
|
|
11
|
+
super().__init__("Publish")
|
|
12
|
+
self._connected = False
|
|
13
|
+
self._busy = False
|
|
14
|
+
form = QFormLayout()
|
|
15
|
+
self._topic = QLineEdit()
|
|
16
|
+
self._topic.setObjectName("publishTopic")
|
|
17
|
+
self._topic.setPlaceholderText("topic/path")
|
|
18
|
+
self._encoding = QComboBox()
|
|
19
|
+
self._encoding.setObjectName("publishEncoding")
|
|
20
|
+
self._encoding.addItem("UTF-8", "utf-8")
|
|
21
|
+
self._encoding.addItem("Base64", "base64")
|
|
22
|
+
form.addRow("Topic", self._topic)
|
|
23
|
+
form.addRow("Encoding", self._encoding)
|
|
24
|
+
self.content_layout.addLayout(form)
|
|
25
|
+
self._payload = QPlainTextEdit()
|
|
26
|
+
self._payload.setObjectName("publishPayload")
|
|
27
|
+
self._payload.setPlaceholderText("Message payload")
|
|
28
|
+
self._payload.setMaximumHeight(120)
|
|
29
|
+
self.content_layout.addWidget(self._payload)
|
|
30
|
+
self._publish = QPushButton("Publish message")
|
|
31
|
+
self._publish.setObjectName("publishButton")
|
|
32
|
+
self._publish.setProperty("primary", True)
|
|
33
|
+
self._publish.clicked.connect(self._emit_publish)
|
|
34
|
+
self._topic.textChanged.connect(self._update_enabled)
|
|
35
|
+
self._payload.textChanged.connect(self._update_enabled)
|
|
36
|
+
self.content_layout.addWidget(self._publish)
|
|
37
|
+
|
|
38
|
+
def render(self, selected_topic: str, connected: bool, busy: bool) -> None:
|
|
39
|
+
self._connected = connected
|
|
40
|
+
self._busy = busy
|
|
41
|
+
if selected_topic and "+" not in selected_topic and "#" not in selected_topic and not self._topic.hasFocus():
|
|
42
|
+
self._topic.setText(selected_topic)
|
|
43
|
+
self._publish.setText("Publishing…" if busy else "Publish message")
|
|
44
|
+
for widget in (self._topic, self._payload, self._encoding):
|
|
45
|
+
widget.setEnabled(not busy)
|
|
46
|
+
self._update_enabled()
|
|
47
|
+
|
|
48
|
+
def _emit_publish(self) -> None:
|
|
49
|
+
self.publish_requested.emit(self._topic.text(), self._payload.toPlainText(), str(self._encoding.currentData()))
|
|
50
|
+
|
|
51
|
+
def _update_enabled(self) -> None:
|
|
52
|
+
topic = self._topic.text().strip()
|
|
53
|
+
self._publish.setEnabled(
|
|
54
|
+
self._connected
|
|
55
|
+
and not self._busy
|
|
56
|
+
and bool(topic)
|
|
57
|
+
and "+" not in topic
|
|
58
|
+
and "#" not in topic
|
|
59
|
+
and bool(self._payload.toPlainText())
|
|
60
|
+
)
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
from PySide6.QtCore import QLocale, Qt, Signal
|
|
2
|
+
from PySide6.QtGui import QDoubleValidator
|
|
3
|
+
from PySide6.QtWidgets import (
|
|
4
|
+
QFormLayout,
|
|
5
|
+
QFrame,
|
|
6
|
+
QGroupBox,
|
|
7
|
+
QHBoxLayout,
|
|
8
|
+
QLabel,
|
|
9
|
+
QLineEdit,
|
|
10
|
+
QPushButton,
|
|
11
|
+
QSpinBox,
|
|
12
|
+
QToolButton,
|
|
13
|
+
QVBoxLayout,
|
|
14
|
+
QWidget,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from topicgate.app.services.broker_snapshot_service import (
|
|
18
|
+
MAX_SNAPSHOT_RESULT_LIMIT,
|
|
19
|
+
)
|
|
20
|
+
from topicgate.core.payload_limits import MAX_RENDERED_PAYLOAD_BYTES
|
|
21
|
+
from topicgate.presentation.snapshot_presentation import (
|
|
22
|
+
BrokerSnapshotHealth,
|
|
23
|
+
SnapshotQuery,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SnapshotPanel(QWidget):
|
|
28
|
+
"""Collapsible snapshot controls and broker-wide health summary."""
|
|
29
|
+
|
|
30
|
+
apply_requested = Signal(object)
|
|
31
|
+
reset_requested = Signal()
|
|
32
|
+
reconnect_observe_requested = Signal(object)
|
|
33
|
+
validation_failed = Signal(str)
|
|
34
|
+
expansion_changed = Signal(bool)
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
super().__init__()
|
|
38
|
+
self.setObjectName("snapshotPanel")
|
|
39
|
+
self.setMinimumWidth(0)
|
|
40
|
+
layout = QVBoxLayout(self)
|
|
41
|
+
layout.setContentsMargins(0, 4, 0, 0)
|
|
42
|
+
layout.setSpacing(6)
|
|
43
|
+
|
|
44
|
+
header = QFrame()
|
|
45
|
+
header.setObjectName("snapshotHeader")
|
|
46
|
+
header_layout = QVBoxLayout(header)
|
|
47
|
+
header_layout.setContentsMargins(8, 3, 8, 3)
|
|
48
|
+
header_layout.setSpacing(2)
|
|
49
|
+
primary_summary = QHBoxLayout()
|
|
50
|
+
primary_summary.setSpacing(7)
|
|
51
|
+
self._toggle = QToolButton()
|
|
52
|
+
self._toggle.setObjectName("snapshotToggleButton")
|
|
53
|
+
self._toggle.setText("Snapshot")
|
|
54
|
+
self._toggle.setCheckable(True)
|
|
55
|
+
self._toggle.setChecked(False)
|
|
56
|
+
self._toggle.setArrowType(Qt.ArrowType.RightArrow)
|
|
57
|
+
self._toggle.setToolButtonStyle(
|
|
58
|
+
Qt.ToolButtonStyle.ToolButtonTextBesideIcon
|
|
59
|
+
)
|
|
60
|
+
self._toggle.setAccessibleName("Snapshot details")
|
|
61
|
+
self._toggle.toggled.connect(self._set_expanded)
|
|
62
|
+
primary_summary.addWidget(self._toggle)
|
|
63
|
+
primary_summary.addStretch(1)
|
|
64
|
+
self._summary_labels = {
|
|
65
|
+
name: self._summary_label(object_name)
|
|
66
|
+
for name, object_name in (
|
|
67
|
+
("connection", "snapshotSummaryConnection"),
|
|
68
|
+
("returned", "snapshotSummaryReturned"),
|
|
69
|
+
("dropped", "snapshotSummaryDropped"),
|
|
70
|
+
("completeness", "snapshotSummaryCompleteness"),
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
primary_summary.addWidget(self._summary_labels["connection"])
|
|
74
|
+
primary_summary.addWidget(self._summary_labels["completeness"])
|
|
75
|
+
secondary_summary = QHBoxLayout()
|
|
76
|
+
secondary_summary.setSpacing(12)
|
|
77
|
+
secondary_summary.addStretch(1)
|
|
78
|
+
secondary_summary.addWidget(self._summary_labels["returned"])
|
|
79
|
+
secondary_summary.addWidget(self._summary_labels["dropped"])
|
|
80
|
+
header_layout.addLayout(primary_summary)
|
|
81
|
+
header_layout.addLayout(secondary_summary)
|
|
82
|
+
layout.addWidget(header)
|
|
83
|
+
|
|
84
|
+
self._content = QWidget()
|
|
85
|
+
self._content.setObjectName("snapshotContent")
|
|
86
|
+
content_layout = QVBoxLayout(self._content)
|
|
87
|
+
content_layout.setContentsMargins(0, 0, 0, 0)
|
|
88
|
+
content_layout.setSpacing(6)
|
|
89
|
+
|
|
90
|
+
controls = QGroupBox("Snapshot filters")
|
|
91
|
+
controls.setObjectName("snapshotControls")
|
|
92
|
+
form = QFormLayout(controls)
|
|
93
|
+
form.setRowWrapPolicy(QFormLayout.RowWrapPolicy.WrapLongRows)
|
|
94
|
+
self._topic_filter = QLineEdit("#")
|
|
95
|
+
self._topic_filter.setObjectName("snapshotTopicFilter")
|
|
96
|
+
self._topic_filter.setAccessibleName("Snapshot topic filter")
|
|
97
|
+
self._maximum_age = QLineEdit()
|
|
98
|
+
self._maximum_age.setObjectName("snapshotMaximumAge")
|
|
99
|
+
self._maximum_age.setAccessibleName("Snapshot maximum age in seconds")
|
|
100
|
+
self._maximum_age.setPlaceholderText("No maximum")
|
|
101
|
+
age_validator = QDoubleValidator(0.0, float("inf"), 3, self)
|
|
102
|
+
age_validator.setLocale(QLocale.c())
|
|
103
|
+
self._maximum_age.setValidator(age_validator)
|
|
104
|
+
self._result_limit = QSpinBox()
|
|
105
|
+
self._result_limit.setObjectName("snapshotResultLimit")
|
|
106
|
+
self._result_limit.setAccessibleName("Snapshot result limit")
|
|
107
|
+
self._result_limit.setRange(1, MAX_SNAPSHOT_RESULT_LIMIT)
|
|
108
|
+
self._payload_limit = QSpinBox()
|
|
109
|
+
self._payload_limit.setObjectName("snapshotPayloadLimit")
|
|
110
|
+
self._payload_limit.setAccessibleName("Snapshot payload rendering limit in bytes")
|
|
111
|
+
self._payload_limit.setRange(0, MAX_RENDERED_PAYLOAD_BYTES)
|
|
112
|
+
self._payload_limit.setSuffix(" bytes")
|
|
113
|
+
form.addRow("Topic filter", self._topic_filter)
|
|
114
|
+
form.addRow("Maximum age (seconds)", self._maximum_age)
|
|
115
|
+
form.addRow("Result limit", self._result_limit)
|
|
116
|
+
form.addRow("Payload rendering limit", self._payload_limit)
|
|
117
|
+
|
|
118
|
+
button_row = QHBoxLayout()
|
|
119
|
+
apply_button = QPushButton("Apply")
|
|
120
|
+
apply_button.setObjectName("applySnapshotButton")
|
|
121
|
+
apply_button.setAccessibleName("Apply snapshot filters")
|
|
122
|
+
clear_button = QPushButton("Clear filters")
|
|
123
|
+
clear_button.setObjectName("clearSnapshotFiltersButton")
|
|
124
|
+
clear_button.setAccessibleName("Clear snapshot filters")
|
|
125
|
+
apply_button.clicked.connect(self._emit_apply)
|
|
126
|
+
clear_button.clicked.connect(self._clear_filters)
|
|
127
|
+
button_row.addWidget(apply_button)
|
|
128
|
+
button_row.addWidget(clear_button)
|
|
129
|
+
form.addRow(button_row)
|
|
130
|
+
|
|
131
|
+
self._warning = QLabel(
|
|
132
|
+
"Reconnect & observe interrupts and renews the active broker "
|
|
133
|
+
"connection before capturing a new snapshot."
|
|
134
|
+
)
|
|
135
|
+
self._warning.setObjectName("reconnectObserveWarning")
|
|
136
|
+
self._warning.setWordWrap(True)
|
|
137
|
+
self._warning.setStyleSheet("color: #92400e;")
|
|
138
|
+
form.addRow(self._warning)
|
|
139
|
+
observe_button = QPushButton("Reconnect && observe")
|
|
140
|
+
observe_button.setObjectName("reconnectObserveButton")
|
|
141
|
+
observe_button.setAccessibleName("Reconnect & observe")
|
|
142
|
+
observe_button.clicked.connect(self._emit_observe)
|
|
143
|
+
form.addRow(observe_button)
|
|
144
|
+
content_layout.addWidget(controls)
|
|
145
|
+
|
|
146
|
+
legend = QLabel(
|
|
147
|
+
"Value legend: Live = received during this run. Cached = restored from local storage. "
|
|
148
|
+
"Stale = older than the current observation window. Stored = persisted source."
|
|
149
|
+
)
|
|
150
|
+
legend.setObjectName("snapshotFreshnessLegend")
|
|
151
|
+
legend.setWordWrap(True)
|
|
152
|
+
legend.setTextFormat(Qt.TextFormat.PlainText)
|
|
153
|
+
legend.setAccessibleName("Freshness and source legend")
|
|
154
|
+
content_layout.addWidget(legend)
|
|
155
|
+
|
|
156
|
+
health_group = QGroupBox("Snapshot health")
|
|
157
|
+
health_group.setObjectName("snapshotHealthPanel")
|
|
158
|
+
health_form = QFormLayout(health_group)
|
|
159
|
+
health_form.setRowWrapPolicy(QFormLayout.RowWrapPolicy.WrapLongRows)
|
|
160
|
+
self._health_labels = {
|
|
161
|
+
name: self._label(object_name)
|
|
162
|
+
for name, object_name in (
|
|
163
|
+
("captured", "snapshotCapturedAt"),
|
|
164
|
+
("connected", "snapshotConnectedAt"),
|
|
165
|
+
("observation", "snapshotObservationStartedAt"),
|
|
166
|
+
("duration", "snapshotObservationDuration"),
|
|
167
|
+
("returned", "snapshotReturnedCount"),
|
|
168
|
+
("omitted", "snapshotOmittedCount"),
|
|
169
|
+
("stale", "snapshotStaleCount"),
|
|
170
|
+
("truncated", "snapshotTruncatedCount"),
|
|
171
|
+
("dropped", "snapshotDroppedCount"),
|
|
172
|
+
("completeness", "snapshotCompletenessStatus"),
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
for title, key in (
|
|
176
|
+
("Captured", "captured"),
|
|
177
|
+
("Connected", "connected"),
|
|
178
|
+
("Observation", "observation"),
|
|
179
|
+
("Observed for", "duration"),
|
|
180
|
+
("Returned", "returned"),
|
|
181
|
+
("Omitted", "omitted"),
|
|
182
|
+
("Stale", "stale"),
|
|
183
|
+
("Truncated", "truncated"),
|
|
184
|
+
("Dropped", "dropped"),
|
|
185
|
+
("Completeness", "completeness"),
|
|
186
|
+
):
|
|
187
|
+
health_form.addRow(title, self._health_labels[key])
|
|
188
|
+
self._limitations = self._label("snapshotLimitations")
|
|
189
|
+
self._limitations.setWordWrap(True)
|
|
190
|
+
health_form.addRow("Limitations", self._limitations)
|
|
191
|
+
content_layout.addWidget(health_group)
|
|
192
|
+
layout.addWidget(self._content)
|
|
193
|
+
|
|
194
|
+
self._action_widgets = (
|
|
195
|
+
apply_button,
|
|
196
|
+
clear_button,
|
|
197
|
+
observe_button,
|
|
198
|
+
)
|
|
199
|
+
self.render_query(SnapshotQuery())
|
|
200
|
+
self.render_connection_status("disconnected")
|
|
201
|
+
self._summary_labels["returned"].setText("Returned 0")
|
|
202
|
+
self._summary_labels["dropped"].setText("Dropped 0")
|
|
203
|
+
self._summary_labels["completeness"].setText("Limited")
|
|
204
|
+
self._set_expanded(False)
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def is_expanded(self) -> bool:
|
|
208
|
+
return self._toggle.isChecked()
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def query(self) -> SnapshotQuery:
|
|
212
|
+
age_text = self._maximum_age.text().strip()
|
|
213
|
+
try:
|
|
214
|
+
maximum_age = None if not age_text else float(age_text)
|
|
215
|
+
except ValueError as error:
|
|
216
|
+
raise ValueError(
|
|
217
|
+
"Maximum age must be a non-negative number or blank."
|
|
218
|
+
) from error
|
|
219
|
+
return SnapshotQuery(
|
|
220
|
+
topic_filter=self._topic_filter.text().strip(),
|
|
221
|
+
max_age_seconds=maximum_age,
|
|
222
|
+
result_limit=self._result_limit.value(),
|
|
223
|
+
payload_limit_bytes=self._payload_limit.value(),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def set_expanded(self, expanded: bool) -> None:
|
|
227
|
+
self._toggle.setChecked(expanded)
|
|
228
|
+
|
|
229
|
+
def render_query(self, query: SnapshotQuery) -> None:
|
|
230
|
+
self._topic_filter.setText(query.topic_filter)
|
|
231
|
+
self._maximum_age.setText(
|
|
232
|
+
"" if query.max_age_seconds is None else str(query.max_age_seconds)
|
|
233
|
+
)
|
|
234
|
+
self._result_limit.setValue(query.result_limit)
|
|
235
|
+
self._payload_limit.setValue(query.payload_limit_bytes)
|
|
236
|
+
|
|
237
|
+
def render_connection_status(self, status: str) -> None:
|
|
238
|
+
value = status.replace("_", " ").title()
|
|
239
|
+
label = self._summary_labels["connection"]
|
|
240
|
+
label.setText(value)
|
|
241
|
+
color = {
|
|
242
|
+
"connected": "#168a55",
|
|
243
|
+
"connecting": "#92400e",
|
|
244
|
+
"reconnecting": "#92400e",
|
|
245
|
+
"disconnected": "#9f2f2f",
|
|
246
|
+
}.get(status.lower(), "#4b5563")
|
|
247
|
+
label.setStyleSheet(f"color: {color};")
|
|
248
|
+
self._update_summary_accessibility()
|
|
249
|
+
|
|
250
|
+
def render_health(self, health: BrokerSnapshotHealth) -> None:
|
|
251
|
+
values = {
|
|
252
|
+
"captured": health.captured_at_label,
|
|
253
|
+
"connected": health.connected_at_label,
|
|
254
|
+
"observation": health.observation_started_at_label,
|
|
255
|
+
"duration": health.observed_for_label,
|
|
256
|
+
"returned": str(health.returned_count),
|
|
257
|
+
"omitted": str(health.omitted_count),
|
|
258
|
+
"stale": str(health.stale_count),
|
|
259
|
+
"truncated": str(health.truncated_count),
|
|
260
|
+
"dropped": str(health.dropped_message_count),
|
|
261
|
+
"completeness": health.completeness_status,
|
|
262
|
+
}
|
|
263
|
+
for name, value in values.items():
|
|
264
|
+
self._health_labels[name].setText(value)
|
|
265
|
+
self._limitations.setText(
|
|
266
|
+
"\n".join(f"- {item}" for item in health.limitation_labels) or "None"
|
|
267
|
+
)
|
|
268
|
+
self._summary_labels["returned"].setText(
|
|
269
|
+
f"Returned {health.returned_count}"
|
|
270
|
+
)
|
|
271
|
+
self._summary_labels["dropped"].setText(
|
|
272
|
+
f"Dropped {health.dropped_message_count}"
|
|
273
|
+
)
|
|
274
|
+
completeness = self._summary_labels["completeness"]
|
|
275
|
+
completeness.setText(health.completeness_status)
|
|
276
|
+
completeness.setStyleSheet(
|
|
277
|
+
"color: #168a55;"
|
|
278
|
+
if health.completeness_status == "Complete"
|
|
279
|
+
else "color: #92400e;"
|
|
280
|
+
)
|
|
281
|
+
self._update_summary_accessibility()
|
|
282
|
+
|
|
283
|
+
def set_busy(self, busy: bool) -> None:
|
|
284
|
+
for widget in self._action_widgets:
|
|
285
|
+
widget.setEnabled(not busy)
|
|
286
|
+
|
|
287
|
+
def _set_expanded(self, expanded: bool) -> None:
|
|
288
|
+
self._content.setVisible(expanded)
|
|
289
|
+
self._toggle.setArrowType(
|
|
290
|
+
Qt.ArrowType.DownArrow if expanded else Qt.ArrowType.RightArrow
|
|
291
|
+
)
|
|
292
|
+
state = "expanded" if expanded else "collapsed"
|
|
293
|
+
self._toggle.setAccessibleDescription(
|
|
294
|
+
f"Snapshot details are {state}."
|
|
295
|
+
)
|
|
296
|
+
action = "Collapse" if expanded else "Expand"
|
|
297
|
+
self._toggle.setAccessibleName(f"{action} snapshot details")
|
|
298
|
+
self._toggle.setToolTip(f"{action} snapshot details")
|
|
299
|
+
self.expansion_changed.emit(expanded)
|
|
300
|
+
|
|
301
|
+
def _update_summary_accessibility(self) -> None:
|
|
302
|
+
summary = ", ".join(
|
|
303
|
+
label.text() for label in self._summary_labels.values()
|
|
304
|
+
)
|
|
305
|
+
self._toggle.setAccessibleDescription(
|
|
306
|
+
f"Snapshot details are {'expanded' if self.is_expanded else 'collapsed'}. "
|
|
307
|
+
f"{summary}."
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def _emit_apply(self) -> None:
|
|
311
|
+
self._emit_query(self.apply_requested)
|
|
312
|
+
|
|
313
|
+
def _emit_observe(self) -> None:
|
|
314
|
+
self._emit_query(self.reconnect_observe_requested)
|
|
315
|
+
|
|
316
|
+
def _clear_filters(self) -> None:
|
|
317
|
+
query = SnapshotQuery()
|
|
318
|
+
self.render_query(query)
|
|
319
|
+
self.reset_requested.emit()
|
|
320
|
+
|
|
321
|
+
def _emit_query(self, signal: Signal) -> None:
|
|
322
|
+
try:
|
|
323
|
+
query = self.query
|
|
324
|
+
except ValueError as error:
|
|
325
|
+
self.validation_failed.emit(str(error))
|
|
326
|
+
return
|
|
327
|
+
signal.emit(query)
|
|
328
|
+
|
|
329
|
+
@staticmethod
|
|
330
|
+
def _label(name: str) -> QLabel:
|
|
331
|
+
label = QLabel("-")
|
|
332
|
+
label.setObjectName(name)
|
|
333
|
+
label.setTextFormat(Qt.TextFormat.PlainText)
|
|
334
|
+
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
|
335
|
+
return label
|
|
336
|
+
|
|
337
|
+
@staticmethod
|
|
338
|
+
def _summary_label(name: str) -> QLabel:
|
|
339
|
+
label = QLabel("-")
|
|
340
|
+
label.setObjectName(name)
|
|
341
|
+
label.setTextFormat(Qt.TextFormat.PlainText)
|
|
342
|
+
label.setStyleSheet("color: #4b5563;")
|
|
343
|
+
return label
|