Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,4 @@ jobs:
- name: Verify Codex plugin bundle
run: .venv/bin/python -m pytest tests/test_plugin_bundle.py -q
- name: Run tests
run: .venv/bin/python -m pytest -q
run: .venv/bin/python -m pytest -n 4 --dist loadfile -q
7 changes: 4 additions & 3 deletions docs/DESKTOP_UX.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ are ignored. There are no local Advanced toggles.
| History | Recording status/control, broker/topic/time filters, pagination, payload and truncation warnings | Adds page size, byte columns, receipt provenance and counters | Recording and message browsing remain core tasks. |
| Health | Overview, actionable checks, expectation conditions and failure history | Adds Changes, failure occurrence counts and history deletion | Understand and resolve failures without diagnostic administration. |
| Expectations | Name, condition, required values/encoding, enabled state, save/delete | Adds action settings, description and revision details | All condition types remain usable, including existing binary rules. Nondefault action settings are summarized and preserved on save. |
| Snapshot | Compact notice of configured bounds and essential empty-state recovery actions | Full Snapshot destination, filters, rendering bounds and diagnostics | Existing view limits stay active and visible as a summary. |
| Snapshot | Snapshot destination with configured bounds and essential recovery actions | Adds detailed diagnostics | Existing view limits stay active and visible as a summary. |
| Storage and profiles | Specialist destinations hidden | Stored observations, cache administration, retention/pruning and Diagnostic profiles | Keep administration out of everyday navigation. |
| Help | Broker recovery, MCP setup, support export and About | Also includes log console | Setup and support stay available in both modes. |

Expand All @@ -27,8 +27,9 @@ boundary**. Switching never connects or disconnects, publishes, changes recordin
expectations or retention, or deletes data. Existing configurations remain active.
History recording is changed only through the explicit **Record messages** action.

The setting updates existing panes and subsequent navigation. Snapshot falls back
to Health; Health → Changes falls back to Overview. Broker/topic context, history
The setting updates existing panes and subsequent navigation. Snapshot remains
available in both modes so its configured bounds can be inspected or reset and
**Reconnect & observe** remains reachable; Health → Changes falls back to Overview. Broker/topic context, history
filters/results and unsent publish text remain intact. Specialist menu actions,
shortcuts and the log dock's context-menu action are unavailable in simplified mode.

Expand Down
Binary file added docs/images/observation-history-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/observation-history-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ apps = [
test = [
"keyrings.alt",
"pytest",
"pytest-xdist",
"pytest-asyncio",
]

Expand Down
12 changes: 8 additions & 4 deletions src/topicgate/gui/components/event_history_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,11 @@ def _request_recording(self, enabled: bool) -> None:
self.render_recording()

def _recording_loaded(self) -> None:
if self._view_model.history_settings_broker == self.broker.currentData():
broker_id = self.broker.currentData()
if (
self._view_model.history_recording_status_for(broker_id) is not None
or self._view_model.history_settings_error_for(broker_id) is not None
):
Comment on lines +154 to +157

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep loading until the requested broker refresh completes

When this broker already has a cached status or error, any history_settings_changed emission clears _recording_loading, even if it came from a different broker while this broker's new request is still running. For example, after visiting both brokers, switching back to broker A and then completing a concurrent broker B load re-enables A's checkbox with stale cached state before A's refresh finishes; associate completion with the specific broker/request rather than treating cache presence as completion.

AGENTS.md reference: AGENTS.md:L32-L32

Useful? React with 👍 / 👎.

self._recording_loading = False
self._recording_pending = False
self.render_recording()
Expand All @@ -163,10 +167,10 @@ def request_recording_status(self) -> None:
def render_recording(self) -> None:
vm = self._view_model
broker = self.broker.currentData()
status = vm.history_recording_status
known = vm.history_settings_broker == broker and status is not None and status.broker_id == broker
status = vm.history_recording_status_for(broker)
known = status is not None and status.broker_id == broker
busy = vm.is_busy("history-settings") or self._recording_pending
error = vm.history_settings_error if vm.history_settings_broker == broker else None
error = vm.history_settings_error_for(broker)
self.enable_recording.setEnabled(known and not busy and not error and not self._recording_loading)
self.enable_recording.setChecked(bool(known and status.enabled))
self.reload_recording.setVisible(bool(error) or not known or self._recording_loading)
Expand Down
25 changes: 20 additions & 5 deletions src/topicgate/gui/main_view_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,13 @@ def __init__(
self._runtime = runtime
self.history_policy: HistoryRetentionPolicy | None = None
self.history_recording_status: HistoryRecordingStatus | None = None
self._history_recording_statuses: dict[UUID, HistoryRecordingStatus] = {}
self._history_settings_errors: dict[UUID, str] = {}
self.history_usage: HistoryUsage | None = None
self.history_settings_broker: UUID | None = None
self.history_settings_error: str | None = None
self.history_settings_feedback = ""
self._history_settings_generation = 0
self._history_settings_generations: dict[UUID, int] = {}
self.event_history_result: TopicHistoryResult | None = None
self.event_history_error: str | None = None
self.event_history_busy = False
Expand Down Expand Up @@ -951,28 +953,40 @@ async def query_event_history(

async def load_history_settings(self, broker_id: UUID) -> None:
self.history_settings_feedback = ""
self._history_settings_generation += 1
generation = self._history_settings_generation
generation = self._history_settings_generations.get(broker_id, 0) + 1
self._history_settings_generations[broker_id] = generation
try:
policy, status, usage = await asyncio.gather(
asyncio.to_thread(self._runtime.get_history_retention_policy),
asyncio.to_thread(self._runtime.get_history_recording_status, broker_id),
asyncio.to_thread(self._runtime.get_history_usage, broker_id),
)
except Exception:
if generation == self._history_settings_generation:
if generation == self._history_settings_generations[broker_id]:
self.history_settings_broker = broker_id
self.history_settings_error = "History settings could not be loaded. Reload to retry."
self.history_policy = None
self._history_settings_errors[broker_id] = self.history_settings_error
self.history_settings_changed.emit()
return
if generation != self._history_settings_generation:
if generation != self._history_settings_generations[broker_id]:
return
self.history_settings_broker = broker_id
self.history_policy, self.history_recording_status, self.history_usage = policy, status, usage
self.history_settings_error = None
self._history_recording_statuses[broker_id] = status
self._history_settings_errors.pop(broker_id, None)
self.history_settings_changed.emit()

def history_recording_status_for(
self,
broker_id: UUID,
) -> HistoryRecordingStatus | None:
return self._history_recording_statuses.get(broker_id)

def history_settings_error_for(self, broker_id: UUID) -> str | None:
return self._history_settings_errors.get(broker_id)

async def set_history_recording(self, broker_id: UUID, enabled: bool) -> None:
"""Record future receipts without changing retention limits."""
async with self._operation("history-settings"):
Expand All @@ -981,6 +995,7 @@ async def set_history_recording(self, broker_id: UUID, enabled: bool) -> None:
except Exception:
self.history_settings_broker = broker_id
self.history_settings_error = "Recording could not be changed. Reload status to retry."
self._history_settings_errors[broker_id] = self.history_settings_error
self.history_settings_changed.emit()
return
await self.load_history_settings(broker_id)
Expand Down
13 changes: 6 additions & 7 deletions src/topicgate/gui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,10 +308,6 @@ def _set_context_panel_visible(self, visible: bool) -> None:
)

def _show_snapshot(self) -> None:
if not self._advanced_mode:
self._inspector_stack.setCurrentWidget(self._health_inspector)
self._context_panel.setHidden(True)
return
self._inspector_stack.setCurrentIndex(0)
self._context_panel.setHidden(True)

Expand Down Expand Up @@ -604,8 +600,8 @@ def _request_advanced_mode(self, advanced: bool) -> None:
def _apply_advanced_mode(self) -> None:
advanced = self._advanced_mode
self._destination_tabs.blockSignals(True)
self._destination_tabs.setTabVisible(2, advanced)
self._destination_tabs.setTabEnabled(2, advanced)
self._destination_tabs.setTabVisible(2, True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Un-padding slices along wrong tensor dimensions

This is a placeholder and should not be here.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Un-padding slices along wrong tensor dimensions

<valid Markdown explaining why this is a problem; cite files/lines/functions>

Useful? React with 👍 / 👎.

self._destination_tabs.setTabEnabled(2, True)
self._destination_tabs.blockSignals(False)
if not advanced and self._inspector_stack.currentIndex() == 0:
self._show_snapshot()
Expand Down Expand Up @@ -1215,7 +1211,10 @@ async def _switch_broker_profile(
await self._view_model.activate_broker_profile(profile_id, mqtt_config)
finally:
if self._view_model.active_broker_profile.id != previous_profile_id:
self._show_snapshot()
if self._advanced_mode:
self._show_snapshot()
else:
self._show_health()
self._render_connection_controls()

def _confirm_delete_broker_profile(
Expand Down
42 changes: 42 additions & 0 deletions tests/test_event_history_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,48 @@ def read_status(broker):
app.processEvents()


async def test_recording_status_loads_complete_independently_per_broker():
app = QApplication.instance() or QApplication([])
runtime = runtime_for(FakeGuiRepository())
vm = MainViewModel(runtime)
workspace = EventHistoryWidget(vm)
other = EventHistoryWidget(vm)
first = workspace.broker.currentData()
other.broker.setCurrentIndex(1)
second = other.broker.currentData()
started, release = Event(), Event()

def read_status(broker):
if broker == first:
started.set()
assert release.wait(5)
return HistoryRecordingStatus(broker, enabled=broker == first)

runtime.get_history_recording_status = Mock(side_effect=read_status)
runtime.get_history_retention_policy = Mock(return_value=HistoryRetentionPolicy())
runtime.get_history_usage = Mock(
side_effect=lambda broker: HistoryUsage(broker, 0, 0, None, None, 0)
)
task = asyncio.create_task(vm.load_history_settings(first))
try:
assert await asyncio.to_thread(started.wait, 2)
await vm.load_history_settings(second)
assert not workspace._recording_loading
assert not workspace.enable_recording.isEnabled()
assert other.enable_recording.isEnabled()
release.set()
await task
assert workspace.enable_recording.isEnabled()
assert workspace.enable_recording.isChecked()
assert not other.enable_recording.isChecked()
finally:
release.set()
await task
workspace.close()
other.close()
app.processEvents()


def test_history_mode_switch_preserves_page_selection_and_payload():
app = QApplication.instance() or QApplication([])
vm = MainViewModel(runtime_for(FakeGuiRepository()))
Expand Down
9 changes: 5 additions & 4 deletions tests/test_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -3312,7 +3312,7 @@ def test_global_advanced_mode_defaults_and_persists(tmp_path, saved_mode) -> Non
window.close()
restored = MainWindow(MainViewModel(runtime_for(FakeGuiRepository())), settings)
assert restored._advanced_mode_action.isChecked() is expected
assert restored._destination_tabs.isTabVisible(2) is expected
assert restored._destination_tabs.isTabVisible(2)
restored.close()
application.processEvents()

Expand All @@ -3329,8 +3329,8 @@ def test_global_mode_visibility_fallback_and_stale_routes() -> None:
for advanced in (False, True, False):
window._advanced_mode_action.setChecked(advanced)
assert window._advanced_mode is advanced
assert window._destination_tabs.isTabVisible(2) is advanced
assert window._destination_tabs.isTabEnabled(2) is advanced
assert window._destination_tabs.isTabVisible(2)
assert window._destination_tabs.isTabEnabled(2)
for action in (window._stored_observations_action, window._console_action,
window._diagnostic_profiles_action):
assert action.isVisible() is advanced
Expand Down Expand Up @@ -3363,7 +3363,8 @@ def test_global_mode_visibility_fallback_and_stale_routes() -> None:
window._diagnostic_profiles_action.trigger()
window._console_action.trigger()
window._navigate(2)
assert window._inspector_stack.currentWidget() is window._health_inspector
assert window._inspector_stack.currentWidget() is not window._health_inspector
assert window._inspector_stack.currentIndex() == 0
assert window._stored_observations_dialog is None
assert window._diagnostic_profile_editor_window is None
assert not window._log_dock.isVisible()
Expand Down
24 changes: 24 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.