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.
Files changed (148) hide show
  1. topicgate/__init__.py +0 -0
  2. topicgate/__main__.py +5 -0
  3. topicgate/app/__init__.py +0 -0
  4. topicgate/app/app_dependencies.py +148 -0
  5. topicgate/app/broker_runtime_state.py +43 -0
  6. topicgate/app/models/__init__.py +1 -0
  7. topicgate/app/models/broker_snapshot.py +107 -0
  8. topicgate/app/models/mcp_setup.py +20 -0
  9. topicgate/app/services/__init__.py +0 -0
  10. topicgate/app/services/broker_profile_service.py +245 -0
  11. topicgate/app/services/broker_resolver.py +58 -0
  12. topicgate/app/services/broker_snapshot_service.py +403 -0
  13. topicgate/app/services/control_operation_service.py +158 -0
  14. topicgate/app/services/mcp_setup_service.py +192 -0
  15. topicgate/app/services/observation_cache_service.py +256 -0
  16. topicgate/app/services/observation_retention_policy_service.py +33 -0
  17. topicgate/app/services/persistence_lifecycle.py +25 -0
  18. topicgate/app/services/service_container.py +20 -0
  19. topicgate/app/services/service_item.py +13 -0
  20. topicgate/app/topicgate_runtime.py +496 -0
  21. topicgate/assets/icon.png +0 -0
  22. topicgate/assets/icon.svg +37 -0
  23. topicgate/core/__init__.py +0 -0
  24. topicgate/core/config/__init__.py +0 -0
  25. topicgate/core/config/app_config.py +7 -0
  26. topicgate/core/config/mqtt_config.py +10 -0
  27. topicgate/core/interfaces/__init__.py +0 -0
  28. topicgate/core/interfaces/broker_profile_store.py +26 -0
  29. topicgate/core/interfaces/mqtt_message_processor.py +12 -0
  30. topicgate/core/interfaces/observer_repository.py +82 -0
  31. topicgate/core/interfaces/topic_message_store.py +13 -0
  32. topicgate/core/models/__init__.py +0 -0
  33. topicgate/core/models/broker_profile.py +15 -0
  34. topicgate/core/models/broker_profile_identity.py +14 -0
  35. topicgate/core/models/broker_summary.py +14 -0
  36. topicgate/core/models/connection_status.py +9 -0
  37. topicgate/core/models/message_filter.py +9 -0
  38. topicgate/core/models/mqtt_message.py +13 -0
  39. topicgate/core/models/mqtt_observation.py +36 -0
  40. topicgate/core/models/mqtt_state.py +5 -0
  41. topicgate/core/models/observation_cache_administration.py +135 -0
  42. topicgate/core/models/observation_deletion_preview.py +39 -0
  43. topicgate/core/models/observation_retention_policy.py +65 -0
  44. topicgate/core/models/observer_model.py +22 -0
  45. topicgate/core/models/observer_workspace.py +13 -0
  46. topicgate/core/models/subscription.py +29 -0
  47. topicgate/core/models/topic_message.py +16 -0
  48. topicgate/core/mqtt_topics.py +46 -0
  49. topicgate/core/observer_limits.py +8 -0
  50. topicgate/core/payload_limits.py +6 -0
  51. topicgate/gui/__init__.py +0 -0
  52. topicgate/gui/app.py +97 -0
  53. topicgate/gui/broker_state_reader.py +29 -0
  54. topicgate/gui/components/about_dialog.py +100 -0
  55. topicgate/gui/components/add_subscription_dialog.py +59 -0
  56. topicgate/gui/components/application_header.py +137 -0
  57. topicgate/gui/components/broker_settings_dialog.py +223 -0
  58. topicgate/gui/components/connection_controls.py +123 -0
  59. topicgate/gui/components/connection_status.py +7 -0
  60. topicgate/gui/components/log_console.py +26 -0
  61. topicgate/gui/components/mcp_setup_dialog.py +198 -0
  62. topicgate/gui/components/observer_tree.py +434 -0
  63. topicgate/gui/components/onboarding_panel.py +74 -0
  64. topicgate/gui/components/publish_pane.py +60 -0
  65. topicgate/gui/components/snapshot_panel.py +343 -0
  66. topicgate/gui/components/stored_observations_dialog.py +611 -0
  67. topicgate/gui/components/subscription_settings.py +151 -0
  68. topicgate/gui/components/topic_details.py +51 -0
  69. topicgate/gui/components/topic_metadata.py +79 -0
  70. topicgate/gui/components/workspace_pane.py +30 -0
  71. topicgate/gui/gui.py +5 -0
  72. topicgate/gui/main_view_model.py +814 -0
  73. topicgate/gui/main_window.py +986 -0
  74. topicgate/gui/observer_state_reader.py +47 -0
  75. topicgate/gui/settings_migration.py +32 -0
  76. topicgate/gui/theme.py +61 -0
  77. topicgate/infrastructure/__init__.py +0 -0
  78. topicgate/infrastructure/credentials/__init__.py +1 -0
  79. topicgate/infrastructure/credentials/credential_store.py +10 -0
  80. topicgate/infrastructure/credentials/os_credential_store.py +20 -0
  81. topicgate/infrastructure/database/__init__.py +0 -0
  82. topicgate/infrastructure/database/base.py +5 -0
  83. topicgate/infrastructure/database/database_context.py +56 -0
  84. topicgate/infrastructure/database/mappers/broker_profile_mapper.py +42 -0
  85. topicgate/infrastructure/database/mappers/config_mapper.py +48 -0
  86. topicgate/infrastructure/database/mappers/mapper_helper.py +24 -0
  87. topicgate/infrastructure/database/mappers/observation_retention_policy_mapper.py +53 -0
  88. topicgate/infrastructure/database/mappers/observer_workspace_mapper.py +42 -0
  89. topicgate/infrastructure/database/mappers/subscription_mapper.py +38 -0
  90. topicgate/infrastructure/database/mappers/topic_message_mapper.py +37 -0
  91. topicgate/infrastructure/database/migrations.py +57 -0
  92. topicgate/infrastructure/database/models/__init__.py +25 -0
  93. topicgate/infrastructure/database/models/app_config_row.py +18 -0
  94. topicgate/infrastructure/database/models/broker_profile_row.py +35 -0
  95. topicgate/infrastructure/database/models/mqtt_config_row.py +18 -0
  96. topicgate/infrastructure/database/models/mqtt_message_row.py +33 -0
  97. topicgate/infrastructure/database/models/observation_retention_policy_row.py +59 -0
  98. topicgate/infrastructure/database/models/observer_workspace_row.py +39 -0
  99. topicgate/infrastructure/database/models/subscription_row.py +18 -0
  100. topicgate/infrastructure/mqtt/__init__.py +0 -0
  101. topicgate/infrastructure/mqtt/async_callback_bridge.py +105 -0
  102. topicgate/infrastructure/mqtt/callbacks/__init__.py +0 -0
  103. topicgate/infrastructure/mqtt/callbacks/basic_callbacks.py +61 -0
  104. topicgate/infrastructure/mqtt/callbacks/observer_repository_callbacks.py +79 -0
  105. topicgate/infrastructure/mqtt/mqtt_callbacks.py +53 -0
  106. topicgate/infrastructure/mqtt/mqtt_client.py +356 -0
  107. topicgate/infrastructure/mqtt/mqtt_gate.py +122 -0
  108. topicgate/infrastructure/repository/__init__.py +0 -0
  109. topicgate/infrastructure/repository/broker_config_repository.py +68 -0
  110. topicgate/infrastructure/repository/broker_repository.py +170 -0
  111. topicgate/infrastructure/repository/observation_retention_policy_repository.py +42 -0
  112. topicgate/infrastructure/repository/observer_mqtt_repository.py +335 -0
  113. topicgate/infrastructure/repository/subscription_repository.py +117 -0
  114. topicgate/infrastructure/repository/topic_message_repository.py +438 -0
  115. topicgate/mcp/__init__.py +0 -0
  116. topicgate/mcp/api/broker_api.py +48 -0
  117. topicgate/mcp/api/connection_api.py +83 -0
  118. topicgate/mcp/api/dashboard_api.py +790 -0
  119. topicgate/mcp/api/dashboard_snapshot.py +278 -0
  120. topicgate/mcp/api/mcp_api.py +18 -0
  121. topicgate/mcp/api/preview/dashboard_preview.py +6 -0
  122. topicgate/mcp/api/publish_api.py +62 -0
  123. topicgate/mcp/api/snapshot_api.py +78 -0
  124. topicgate/mcp/api/subscription_api.py +134 -0
  125. topicgate/mcp/api/topic_api.py +77 -0
  126. topicgate/mcp/capabilities.py +12 -0
  127. topicgate/mcp/instructions.py +3 -0
  128. topicgate/mcp/middleware/__init__.py +6 -0
  129. topicgate/mcp/middleware/error_handling_middleware.py +49 -0
  130. topicgate/mcp/middleware/logging_middleware.py +43 -0
  131. topicgate/mcp/models.py +28 -0
  132. topicgate/mcp/server.py +147 -0
  133. topicgate/paths.py +36 -0
  134. topicgate/presentation/__init__.py +39 -0
  135. topicgate/presentation/retention_presentation.py +187 -0
  136. topicgate/presentation/snapshot_presentation.py +130 -0
  137. topicgate/presentation/topic_presentation.py +482 -0
  138. topicgate/processors/__init__.py +0 -0
  139. topicgate/processors/observation_retention_processor.py +35 -0
  140. topicgate/processors/observer_model_mqtt_message_processor.py +79 -0
  141. topicgate/processors/observer_model_processor.py +223 -0
  142. topicgate/processors/subscription_manager.py +90 -0
  143. topicgate-1.1.0.dist-info/METADATA +222 -0
  144. topicgate-1.1.0.dist-info/RECORD +148 -0
  145. topicgate-1.1.0.dist-info/WHEEL +5 -0
  146. topicgate-1.1.0.dist-info/entry_points.txt +3 -0
  147. topicgate-1.1.0.dist-info/licenses/LICENCE +21 -0
  148. 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