diff --git a/README.md b/README.md index b9b4f02..0e6245a 100644 --- a/README.md +++ b/README.md @@ -69,14 +69,12 @@ Configure a broker in TopicGate Desktop for read-only use, or use the authorized TopicGate snapshots return the latest value it observed and retained, not authoritative broker history. -For individual receipts, open **History** in the workspace and use **Record messages** -for the displayed broker. Recording status appears before Search; enabling or disabling -recording leaves retention limits and saved receipts unchanged. Recording is off by -default for every broker. **History** and the read-only `get_topic_history` tool provide -cursor-paginated receipts with recording and retention limitations. Adjust limits under -**Stored observations → History settings** independently from latest values; large -limits may slow startup and queries. -See [observation history and retention](docs/OBSERVATION_HISTORY.md). +For individual receipts, enable **Stored observations → History settings → Record new +events for this broker**. Recording is off by default for every broker. The separate +**Event history** page and read-only `get_topic_history` tool provide cursor-paginated +history with recording and retention limitations. Adjust history age/count/size limits +independently from latest state; large limits may slow startup and queries. +See [observation history and retention](docs/OBSERVATION_HISTORY.md). - **Live** values arrived in the current observation session. - **Cached** or **stored** values came from local persistence. @@ -86,12 +84,6 @@ See [observation history and retention](docs/OBSERVATION_HISTORY.md). Only the active broker is continuously connected. Check freshness, provenance, truncation, dropped-message count, and completeness when interpreting a snapshot. -Desktop starts with a simplified workspace. **View → Advanced mode** restores -snapshot diagnostics, storage administration, diagnostic profiles and specialist -fields. History and recording remain available in both modes. This saved GUI -preference does not change broker operations or MCP authorization; see the -[desktop mode matrix](docs/DESKTOP_UX.md#simplified-and-advanced-mode). - ## Health expectations Define what healthy means for your broker and topics: an established connection, an expected status payload, a temperature range, or a maximum observation age. Desktop brings broker checks, topic checks, evidence, and failure history into one health view. @@ -100,7 +92,7 @@ Define what healthy means for your broker and topics: an established connection, *Sample health overview. A connected broker can still have failed or unknown checks; the connection badge alone does not establish health.* -Use **Health → Expectations** to browse all rules for the selected broker, with **All / Broker / Topic** scopes. Open a rule to edit it with its target selected, or use **Topic expectations** beside the selected topic. Configure expectations after adding a subscription that covers the topic. See the [desktop workspace guide and UX screenshots](docs/DESKTOP_UX.md). +Use the broker's **Health → Expectations** tab for connection checks, or a topic's **Settings → Expectations** tab for topic conditions. Configure expectations after adding a subscription that covers the topic.
See the broker expectation editor diff --git a/docs/DESKTOP_UX.md b/docs/DESKTOP_UX.md deleted file mode 100644 index 0382b78..0000000 --- a/docs/DESKTOP_UX.md +++ /dev/null @@ -1,283 +0,0 @@ -# Desktop workspace - -The workspace separates MQTT subscriptions, snapshot values, saved receipts, and -health failures. These views describe different scopes; their counts need not match. - -## Simplified and Advanced mode - -The desktop starts in simplified mode when no preference exists. **View → Advanced -mode** is the single application-wide setting; a small status-bar label identifies -Advanced mode. The choice is saved immediately through GUI QSettings -(`workspace/advancedMode`) and restored on restart. Older local disclosure settings -are ignored. There are no local Advanced toggles. - -| Workflow | Simplified mode | Advanced mode | Why | -| --- | --- | --- | --- | -| Brokers | Selection, connection status/actions, profile management, all connection fields | Same | Host, port, credentials and TLS can be essential for setup and recovery. | -| Topics | Browse/search, filter and QoS, readable payload, publishing | Adds retained-message options, raw bytes and detailed metadata | Everyday inspection needs fewer fields; custom retained-message settings are summarized. | -| 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. | -| 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. | - -Advanced mode is a presentation preference, **not an authorization or security -boundary**. Switching never connects or disconnects, publishes, changes recording, -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 -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. - -Turning Advanced mode off is refused while Snapshot, subscription or expectation -forms contain unfinished edits. The notice names the forms; finish saving/applying -or manually restore their original values, then switch again. No automatic save or -discard occurs. Stored observations and Diagnostic profiles must be explicitly -closed before switching, even when clean; finish any edits there first. Broker -forms remain available with their current values. Turning Advanced mode on reveals -fields without reloading drafts. Normal refreshes of unchanged editor context also -preserve unfinished fields. - -## Observe and navigate - -- **Add subscription** changes which MQTT topics TopicGate listens for. -- **Filter displayed topics** narrows the tree locally. It does not subscribe, - publish, reconnect, or change snapshot counts. A visible notice identifies an - active text filter, including when nothing matches it. -- The tree summary shows subscription and snapshot-value counts, the number of - previously stored values, and the active topic filter, age bound, and result cap. - Subscription rows remain visible when no snapshot values match. -- **Live** means received during the current session, not necessarily recently. - Check the selected topic's receive time and age. A value opened directly can be - outside the bounded snapshot; its scope notice explains why it is not counted. -- Use the compact **Health**, **Selected**, **Snapshot**, and **History** tabs - to navigate. Health opens by default; saved topic selection remains available under - Selected. An underline marks the selected destination. **Profiles...**, beside - the broker selector, opens the existing profile menu. The connection action and - connection status stay in the Broker group; its health summary is a separate shortcut. - -## Expectations - -**Health → Expectations** lists configured rules for the selected broker. Choose -**All expectations**, **Broker expectations**, or **Topic expectations** to narrow -the directory. Select a rule and choose **Edit selected expectation**, or double-click -it. Topic rules open with their exact topic and rule selected. **Health** returns to -the previous health tab. **Expectations** sits beside the payload/publish tabs for a -concrete topic. **Close settings** closes the side panel. - -The narrow topic editor shows rule names and results; selecting a rule reveals its -condition and configuration in the form. Save confirms the change in the editor. -Broker forms and settings pages scroll when necessary at smaller window sizes. - -## Message history, latest values, and health failures - -| Destination | What it contains | -| --- | --- | -| Stored observations → Latest stored values | Most recently persisted value for each broker/topic | -| History → Message history | Individual receipts saved while recording was enabled | -| Health → Failure history | Diagnostic failure episodes, not MQTT receipts | - -**History** is always available in the workspace, including while disconnected. -Opening it selects the workspace broker and loads recording status before Search. -The History broker selector can browse another broker without connecting to it. -Returning to History selects the workspace broker again; a broker change clears -results and pagination, while topic/time filters remain available. - -**Record messages** enables or disables recording for the broker displayed immediately -above it. It uses the existing recording service without changing retention or -removing saved receipts. The checkbox is unavailable while applying a change, before -status loads, or after a failure. **Retry** appears when status needs reloading. -Help beside the checkbox explains that recording applies to future receipts and -cannot recover gaps. Query results do not repeat a potentially stale recording state. - -**History settings** provides recording and retention controls. Age and payload size -use the same unit choices as latest-state retention. Optional age and per-topic count -limits use **Unlimited**; mandatory broker-count and global payload limits remain -bounded. Retention settings are available in Advanced mode; pruning batch size and idle -interval appear directly with the other limits. Apply confirms success on the page. - -**Search** starts a fresh message query; **Next page** continues its -fixed boundary. Latest-state sorting and limits remain independent. Red cache -actions identify their scope; **Delete cache for all brokers** affects latest stored -values, not broker profiles, event history, or failure history. Existing deletion -previews and confirmations remain in effect. - -## Layout - -The duplicate broker Snapshot button and History Refresh action were removed. Topic -Expectations shares the payload/publish control row instead of occupying a separate -full-width row. The global mode described above now controls specialist presentation. - -## Earlier implementation evidence - -Publishing keeps encoding next to a multiline payload editor that uses the available -space. Tab moves from the editor to the next control. Publish remains disabled until -its existing connection, topic, payload, and busy-state requirements are met. -Subscription Apply sits beside its form and confirms completion. - -**The screenshots and validation results below belong to the earlier implementation.** -They are preserved as supplied artifacts and do not validate the current changes. -No replacement screenshots were generated; current visual and interaction validation -will be performed by the user. - -These screenshots use an in-memory fixture with no network or real profile storage. -Baseline images were captured before editing. Health is unavailable in the fixture; -that status is not evidence about a real broker. Window checks covered 1280×800, -1024×640, and a maximized Windows desktop. - -| Audit finding | Before | After | -| --- | --- | --- | -| Ambiguous observer state and hidden navigation | [Observer baseline](images/ux-review/before-observer.jpg) | [Observer](images/ux-review/after-observer.jpg) | -| Subscription row with zero matching values | Audit: row and empty-state descriptions conflicted | [Filtered snapshot and selected value](images/ux-review/after-filtered-empty.jpg) | -| Disconnected publish layout | [Publish baseline](images/ux-review/before-publish.jpg) | [Publish editor](images/ux-review/after-publish.jpg) | -| Recording discovery | Audit: status appeared only after Search | [Event history](images/ux-review/after-event-history.jpg) | -| Inconsistent units and technical settings | Audit: raw seconds/bytes and blank limits | [History settings](images/ux-review/after-history-settings.jpg) | -| Separate expectation scopes | Audit: topic checks absent from broker-only tab | [Expectation directory](images/ux-review/after-expectations.jpg) | -| Window-size behavior | Audit had not tested small windows | [Small workspace](images/ux-review/after-small-window.jpg), [maximized](images/ux-review/after-maximized.jpg) | - -![Updated observer workspace](images/ux-review/after-observer.jpg) - -![Recording and retention controls](images/ux-review/after-history-settings.jpg) - -No MQTT semantics, persistence policies, credentials, CLI, or MCP contracts change. -Reconstructing missing receipts, changing snapshot inclusion rules, and altering -retention behavior remain outside this GUI work. - -Validation used the nearby GUI/view-model tests and the full repository suite. -Computer Use verified navigation, the recording enable action on an in-memory -profile, advanced disclosure, disabled controls, and clearing snapshot filters. -Its Qt text-entry API reported stale focus and a UIA cache error; keyboard focus -preservation and no-match text filtering were checked in Qt regression tests. -Real broker publishing, reconnects, recording changes, and data deletion were -not used as UX tests. Windows was checked; other desktop platforms were not. - -Validation commands and results: - -```powershell -uv run pytest tests/test_gui.py tests/test_event_history_gui.py tests/test_desktop_snapshot_states.py tests/test_main_view_model.py tests/test_snapshot_presentation.py -q -# 143 passed -uv run pytest tests/test_gui.py::test_subscription_apply_feedback_and_small_window_layout -q -# 1 passed after the focus-preservation change -uv run pytest tests/test_event_history_gui.py -q -# 6 passed after the broker-scope guard -uv run pytest -# Final code: 695 passed, 1 skipped, 2 warnings in 98.52 seconds -git diff --check -# Passed; Git reports local LF-to-CRLF conversion warnings -``` - -The full-suite warnings concern SQLite's deprecated default datetime adapter in -migration tests. The skipped test is in `tests/test_health_wait_wire.py`. - - -## Earlier navigation pass: manual validation - -Automated tests verify state, signals, scoping, and layout constraints; they do not -establish visual usability. Validate the current interface at 1024×640 and a larger -window, including keyboard navigation: - -- Open History without visiting storage settings. Confirm the broker, recording - status, and Record messages checkbox are immediately understandable. -- In an isolated test profile, enable and disable recording. Confirm pending/error - feedback and that browsing another broker does not change the connection. -- Search history, inspect a payload, and use Next page. Change broker or filters and - confirm old results clear. Return from Snapshot and check the workspace broker. -- Switch tabs and follow health/expectation shortcuts. Confirm the selected tab, - topic context, and unfinished settings edits remain understandable and intact. -- Open Profiles beside the selector; check management access, connection action, - and separate health status. Check that fields and actions fit at the smaller size. -- Distinguish History receipts, Stored observations → Latest stored values, and - Health → Failure history. Check storage settings remain readable. - -All ten supplied `docs/images/ux-review` artifacts were verified unchanged by -SHA-256. This pass did not create screenshots or operate real brokers. - - -## Earlier navigation pass: automated checks - -```powershell -uv run pytest tests/test_gui.py tests/test_event_history_gui.py tests/test_desktop_snapshot_states.py tests/test_main_view_model.py -q -# 140 passed in 46.27 seconds -uv run pytest tests/test_event_history_gui.py -q -# 8 passed in 2.28 seconds after the recording reload guard -uv run pytest -# 697 passed, 1 skipped, 2 warnings in 112.34 seconds -git diff --check -# Passed -``` - -The two warnings are SQLite datetime-adapter deprecations in migration tests; -`tests/test_health_wait_wire.py` is skipped. Regression coverage includes History -navigation without mutation, broker-scoped recording enable/disable, unchanged -retention, repeat-click protection, stale status responses, loading-state guards, -pagination reset, and existing topic/settings navigation. - - -Navigation-order follow-up: **Health (default) | Selected | Snapshot | History**. -Startup keeps any saved topic available under Selected while opening Health. - -```powershell -uv run pytest tests/test_gui.py tests/test_event_history_gui.py -q -# 90 passed in 49.61 seconds -uv run pytest -# 697 passed, 1 skipped, 2 SQLite deprecation warnings in 104.17 seconds -git diff --check -# Passed -``` - -Manual check: restart with a saved topic, confirm Health is selected, then open -Selected and confirm that topic is still available. - - -## Advanced mode: manual validation - -Visual and interaction validation is assigned to the user. Existing screenshot and -validation artifacts are preserved; no replacement screenshots were generated. -Check at 1024×640 and a larger size, including keyboard navigation: - -- Start without a saved mode: confirm Health, Selected and History are the main - destinations, while Snapshot, Stored observations, Diagnostic profiles, Changes - and log-console routes are absent. Check Ctrl+Shift+S and dock context menus. -- Use broker selection, profile editing, TLS/credentials, connect/disconnect, topic - search, subscriptions, payload inspection, publishing and basic expectations in - an isolated test profile. Required fields and recovery actions should be reachable. -- Open History directly: verify broker scope, recording status, Record messages, - Retry after errors, topic/time filters, payload, truncation notices and Next page. -- Enable Advanced mode: confirm all former capabilities and detailed fields return, - with one restrained indicator and no local Advanced switches. Reopen specialist - dialogs and verify retention/pruning, diagnostic profiles and cache controls. -- Switch off from Snapshot and Health → Changes: confirm Health/Overview fallback - and unchanged topic/broker context. Keep a selected history result and unsent - publish draft across switches; verify the same result and text afterward. -- Edit Snapshot, subscription, and broker/topic expectations; attempt to switch - off. Confirm the mode remains Advanced, the notice names the form, and every - draft value remains intact. Resolve edits and retry. Repeat with an open storage - or profile editor; it must remain open until explicitly closed. -- Use existing custom retained-message options, snapshot bounds, binary expectation - values and nondefault expectation actions. Confirm concise summaries in simplified - mode, required encoding fields, and preservation after an explicit basic save. -- Restart in each mode and confirm persistence. Check that switching alone changes - no connection, recording, configured checks, retention or stored data. - - -## Advanced mode: automated checks - -```powershell -uv run pytest tests/test_gui.py tests/test_event_history_gui.py tests/test_desktop_snapshot_states.py tests/test_main_view_model.py -q --tb=short -# 153 passed in 56.77 seconds -uv run pytest -# 710 passed, 1 skipped, 2 warnings in 112.02 seconds -git diff --check -# Passed (Git reports LF-to-CRLF conversion for observer_tree.py) -``` - -The skip is `tests/test_health_wait_wire.py`. Both warnings are SQLite datetime -adapter deprecations in migration tests. Tests use isolated GUI settings and fake -services; mode coverage includes persistence, visibility after refresh/navigation, -blocked specialist routes, fallback, dirty forms, hidden configuration preservation, -history selection and payload preservation, and absence of operational calls. -All ten supplied UX images were compared byte-for-byte with the Git index and -remain unchanged. Existing validation records above describe earlier passes. diff --git a/docs/OBSERVATION_HISTORY.md b/docs/OBSERVATION_HISTORY.md index 8fba237..2fa523e 100644 --- a/docs/OBSERVATION_HISTORY.md +++ b/docs/OBSERVATION_HISTORY.md @@ -29,24 +29,12 @@ before broker deletion; a drain timeout aborts deletion. ## Opt-in and independent retention -Open **History**, select a broker, and choose -**Record messages**. Its status is shown before Search. This action -enables or disables recording for future receipts without changing retention limits. -To adjust limits, enable **View → Advanced mode**, then open -**Stored observations → History settings**, select the broker, -edit the controls, and choose **Apply history settings**. The shared history +Open **Stored observations → History settings**, select a broker, check +**Record new events for this broker (opt-in)** and apply. The shared history limits start at seven days, 100,000 events per broker, no per-topic cap, and -256 MiB of stored payloads globally. Use **Unlimited** for age or per-topic caps. -Age and payload limits have unit selectors; batch size and the idle interval appear -directly with the other settings. There is no local Advanced toggle. Saved changes show confirmation on the page. +256 MiB of stored payloads globally. Age and per-topic caps can be left blank. Large history retention limits may slow startup and history queries. -**Search** starts a fresh query including newly saved events. -**Next page** continues the current query's fixed boundary. Event history is oldest -first; latest stored state has independent sorting and result limits. These views -do not replace **Health → Failure history**. See the [desktop guide](DESKTOP_UX.md) -for screenshots. - The size budget counts stored payload bytes, not database file bytes, indexes, or filesystem allocation. Zero-byte events still count toward event limits. Policy changes validate before persistence. Pruning applies age, per-topic count, @@ -86,17 +74,12 @@ coverage. Settings summaries and eviction generations apply globally where label ## Desktop pages -**Stored observations → Latest stored values** (Advanced mode) shows one persisted -value per topic. The main History destination and recording controls are available -in both presentation modes. -The workspace **History** tab shows individual receipts using the same bounded query -as MCP. **Record messages** enables or disables recording for its displayed broker -without changing retention. Search starts over; Next page continues the committed snapshot. +**Latest stored state** shows one persisted value per topic. **Event history** +shows individual receipts using the same bounded query as MCP. Search or +Refresh snapshot starts over; Next page continues the committed snapshot. Changing brokers or filters resets the cursor. Payloads are displayed as plain text or base64, with provenance and truncation details on selection. -These screenshots show the earlier dialog layout; message history now lives in the workspace. - ![Latest stored state and separate event history with recording limitations.](images/observation-history-after.png) ![Per-broker recording opt-in and independent history limits.](images/observation-history-settings.png) diff --git a/docs/images/desktop-app.png b/docs/images/desktop-app.png new file mode 100644 index 0000000..1a52f65 Binary files /dev/null and b/docs/images/desktop-app.png differ diff --git a/docs/images/desktop-first-run-checklist.png b/docs/images/desktop-first-run-checklist.png new file mode 100644 index 0000000..cd15306 Binary files /dev/null and b/docs/images/desktop-first-run-checklist.png differ diff --git a/docs/images/observation-history-after.png b/docs/images/observation-history-after.png new file mode 100644 index 0000000..205439c Binary files /dev/null and b/docs/images/observation-history-after.png differ diff --git a/docs/images/observation-history-before.png b/docs/images/observation-history-before.png new file mode 100644 index 0000000..9cad5fd Binary files /dev/null and b/docs/images/observation-history-before.png differ diff --git a/docs/images/observation-history-settings.png b/docs/images/observation-history-settings.png new file mode 100644 index 0000000..93117bc Binary files /dev/null and b/docs/images/observation-history-settings.png differ diff --git a/docs/images/plugin_in_codex.png b/docs/images/plugin_in_codex.png new file mode 100644 index 0000000..97ed8f9 Binary files /dev/null and b/docs/images/plugin_in_codex.png differ diff --git a/pyproject.toml b/pyproject.toml index 75fc4b6..9ab606e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,6 @@ where = ["src"] topicgate = [ "assets/*.png", "assets/*.svg", - "assets/icons/*.svg", "infrastructure/database/alembic.ini", "infrastructure/database/alembic/*.py", "infrastructure/database/alembic/*.mako", diff --git a/src/topicgate/assets/delete.svg b/src/topicgate/assets/delete.svg new file mode 100644 index 0000000..1ba8d04 --- /dev/null +++ b/src/topicgate/assets/delete.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/topicgate/assets/edit.svg b/src/topicgate/assets/edit.svg new file mode 100644 index 0000000..2db66d4 --- /dev/null +++ b/src/topicgate/assets/edit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/topicgate/assets/icons/broker.svg b/src/topicgate/assets/icons/broker.svg deleted file mode 100644 index 7ba7a06..0000000 --- a/src/topicgate/assets/icons/broker.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - diff --git a/src/topicgate/assets/icons/close.svg b/src/topicgate/assets/icons/close.svg deleted file mode 100644 index a60b987..0000000 --- a/src/topicgate/assets/icons/close.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/src/topicgate/assets/icons/create.svg b/src/topicgate/assets/icons/create.svg deleted file mode 100644 index 8d8336f..0000000 --- a/src/topicgate/assets/icons/create.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/src/topicgate/assets/icons/delete.svg b/src/topicgate/assets/icons/delete.svg deleted file mode 100644 index cf36563..0000000 --- a/src/topicgate/assets/icons/delete.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - diff --git a/src/topicgate/assets/icons/edit.svg b/src/topicgate/assets/icons/edit.svg deleted file mode 100644 index e06a749..0000000 --- a/src/topicgate/assets/icons/edit.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/src/topicgate/assets/icons/help.svg b/src/topicgate/assets/icons/help.svg deleted file mode 100644 index b30bffe..0000000 --- a/src/topicgate/assets/icons/help.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/src/topicgate/assets/icons/observer-tree.svg b/src/topicgate/assets/icons/observer-tree.svg deleted file mode 100644 index 9b6bb85..0000000 --- a/src/topicgate/assets/icons/observer-tree.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - diff --git a/src/topicgate/assets/icons/settings.svg b/src/topicgate/assets/icons/settings.svg deleted file mode 100644 index 78c4a56..0000000 --- a/src/topicgate/assets/icons/settings.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/src/topicgate/gui/components/about_dialog.py b/src/topicgate/gui/components/about_dialog.py index 4dea02a..acf1742 100644 --- a/src/topicgate/gui/components/about_dialog.py +++ b/src/topicgate/gui/components/about_dialog.py @@ -1,7 +1,5 @@ from importlib.metadata import PackageNotFoundError, version -from topicgate.gui.icons import IconName, icon - from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QDialog, @@ -91,7 +89,6 @@ def __init__(self, parent: QWidget | None = None) -> None: layout.addWidget(license_label) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) - buttons.button(QDialogButtonBox.StandardButton.Close).setIcon(icon(IconName.CLOSE)) buttons.rejected.connect(self.reject) layout.addWidget(buttons) diff --git a/src/topicgate/gui/components/broker_connection.py b/src/topicgate/gui/components/broker_connection.py index 928867b..d45abfb 100644 --- a/src/topicgate/gui/components/broker_connection.py +++ b/src/topicgate/gui/components/broker_connection.py @@ -18,7 +18,7 @@ WORKSPACE_CONTROL_HEIGHT, WorkspacePane, ) -from topicgate.gui.icons import IconName, icon +from topicgate.gui.icons import delete_icon, edit_icon from topicgate.gui.main_view_model import MainViewModel @@ -62,7 +62,7 @@ def __init__( edit_button = QToolButton() edit_button.setObjectName("editBrokerProfileButton") - edit_button.setIcon(icon(IconName.EDIT)) + edit_button.setIcon(edit_icon()) edit_button.setIconSize(QSize(14, 14)) edit_button.setToolButtonStyle( Qt.ToolButtonStyle.ToolButtonTextBesideIcon @@ -75,7 +75,7 @@ def __init__( delete_button = QToolButton() delete_button.setObjectName("deleteBrokerProfileButton") - delete_button.setIcon(icon(IconName.DELETE)) + delete_button.setIcon(delete_icon()) delete_button.setIconSize(QSize(14, 14)) delete_button.setToolButtonStyle( Qt.ToolButtonStyle.ToolButtonTextBesideIcon @@ -161,7 +161,7 @@ def _rebuild_popup(self, active_profile_id: object) -> None: self._popup_menu.addAction(action) self._popup_menu.addSeparator() - add_action = QAction(icon(IconName.CREATE), "Add Broker", self._popup_menu) + add_action = QAction("+ Add Broker", self._popup_menu) add_action.setObjectName("addBrokerProfilePaneAction") add_action.setEnabled(self._management_enabled) add_action.triggered.connect(self._request_add) @@ -199,6 +199,7 @@ class BrokerConnectionPane(WorkspacePane): connect_requested = Signal() reconnect_requested = Signal() disconnect_requested = Signal() + inspect_snapshot_requested = Signal() health_requested = Signal() _STATUS_LABELS = { @@ -210,16 +211,12 @@ class BrokerConnectionPane(WorkspacePane): def __init__(self) -> None: super().__init__("Broker", minimum_hint_width=320) - heading_icon = QLabel() - heading_icon.setObjectName("brokerHeadingIcon") - heading_icon.setAccessibleName("Broker") - heading_icon.setPixmap(icon(IconName.BROKER).pixmap(16, 16)) - self.header_layout.insertWidget(0, heading_icon) self.setObjectName("brokerConnectionPane") self.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed, ) + self.setMaximumHeight(112) self._status = "disconnected" self._status_badge = QLabel("Disconnected") @@ -230,37 +227,19 @@ def __init__(self) -> None: QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Preferred, ) - self.header_layout.setStretch(1, 0) + self.header_layout.setStretch(0, 0) self.header_layout.addWidget(self._status_badge) self.header_layout.addStretch(1) - self._manage_button = QToolButton() - self._manage_button.setText("Profiles…") - self._manage_button.setIcon(icon(IconName.SETTINGS)) - self._manage_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) - self._manage_button.setAccessibleName("Manage broker profiles") - self._manage_button.setFixedHeight(WORKSPACE_CONTROL_HEIGHT) - self._manage_button.setSizePolicy( - QSizePolicy.Policy.Fixed, - QSizePolicy.Policy.Fixed, - ) - self._manage_button.setObjectName("manageBrokersButton") - self._manage_button.clicked.connect(lambda: self._profile_selector.showPopup()) broker_grid = QGridLayout() - broker_grid.setHorizontalSpacing(8) - broker_grid.setVerticalSpacing(6) + broker_grid.setSpacing(8) broker_grid.setColumnStretch(0, 1) broker_grid.setColumnStretch(1, 0) - broker_grid.setColumnStretch(2, 0) self._profile_selector = BrokerProfileSelector() self._profile_selector.setObjectName("connectionBrokerSelector") self._profile_selector.setFixedHeight(WORKSPACE_CONTROL_HEIGHT) - self._profile_selector.setSizePolicy( - QSizePolicy.Policy.Expanding, - QSizePolicy.Policy.Fixed, - ) self._profile_selector.setAccessibleName("Active broker profile") - self._profile_selector.setMinimumWidth(80) + self._profile_selector.setMinimumWidth(160) self._profile_selector.currentIndexChanged.connect( self._select_profile ) @@ -274,30 +253,29 @@ def __init__(self) -> None: self.add_profile_requested.emit ) + self._inspect_snapshot_button = QPushButton("Inspect snapshot") + self._inspect_snapshot_button.setObjectName("inspectSnapshotButton") + self._inspect_snapshot_button.setFixedHeight(WORKSPACE_CONTROL_HEIGHT) + self._inspect_snapshot_button.setAccessibleName("Inspect broker snapshot") + self._inspect_snapshot_button.clicked.connect( + self.inspect_snapshot_requested.emit + ) + self._lifecycle_button = QPushButton("Connect") self._lifecycle_button.setObjectName("brokerLifecycleButton") self._lifecycle_button.setFixedHeight(WORKSPACE_CONTROL_HEIGHT) - self._lifecycle_button.setSizePolicy( - QSizePolicy.Policy.Fixed, - QSizePolicy.Policy.Fixed, - ) self._lifecycle_button.setProperty("primary", True) self._lifecycle_button.clicked.connect(self._request_lifecycle_operation) - self._health_button = QPushButton() - self._health_button.setText("Health: Not evaluated") + self._health_button = QPushButton("Health: Not evaluated") self._health_button.setObjectName("brokerHealthSummary") self._health_button.setFixedHeight(WORKSPACE_CONTROL_HEIGHT) - self._health_button.setSizePolicy( - QSizePolicy.Policy.Expanding, - QSizePolicy.Policy.Fixed, - ) self._health_button.setAccessibleName("Inspect broker health") self._health_button.clicked.connect(self.health_requested.emit) broker_grid.addWidget(self._profile_selector, 0, 0) - broker_grid.addWidget(self._manage_button, 0, 1) - broker_grid.addWidget(self._lifecycle_button, 0, 2) - broker_grid.addWidget(self._health_button, 1, 0, 1, 3) + broker_grid.addWidget(self._lifecycle_button, 0, 1) + broker_grid.addWidget(self._health_button, 1, 0) + broker_grid.addWidget(self._inspect_snapshot_button, 1, 1) self.content_layout.addLayout(broker_grid) self.setMaximumHeight(152) @@ -323,16 +301,9 @@ def render(self, view_model: MainViewModel, busy: bool = False) -> None: management_enabled, ) self._profile_selector.setEnabled(management_enabled) - self._manage_button.setEnabled(management_enabled) lifecycle_text, lifecycle_enabled = self._lifecycle_presentation(busy) self._lifecycle_button.setText(lifecycle_text) self._lifecycle_button.setEnabled(lifecycle_enabled) - self._lifecycle_button.setProperty( - "primary", - self._status in {"disconnected", "connecting"}, - ) - self._lifecycle_button.style().unpolish(self._lifecycle_button) - self._lifecycle_button.style().polish(self._lifecycle_button) health = view_model.health_summary counts = self._compact_health_counts(health.label, health.counts) suffix = f" · {counts}" if counts else "" diff --git a/src/topicgate/gui/components/connection_controls.py b/src/topicgate/gui/components/connection_controls.py index c9e026c..fadb9ab 100644 --- a/src/topicgate/gui/components/connection_controls.py +++ b/src/topicgate/gui/components/connection_controls.py @@ -1,8 +1,6 @@ from collections.abc import Callable from uuid import UUID -from topicgate.gui.icons import IconName, icon - from PySide6.QtCore import QObject, QSize, Qt, Signal from PySide6.QtGui import QAction, QColor, QIcon, QPainter, QPixmap from PySide6.QtWidgets import QMenu, QSizePolicy, QToolButton @@ -213,7 +211,6 @@ def _sync_edit_actions( continue action = QAction(profile.name, self) action.setObjectName("editBrokerProfileAction") - action.setIcon(icon(IconName.EDIT)) action.triggered.connect( lambda _checked=False, profile_id=profile.id: ( self.edit_profile_requested.emit(profile_id) diff --git a/src/topicgate/gui/components/diagnostic_profile_editor.py b/src/topicgate/gui/components/diagnostic_profile_editor.py index a89c30c..e58ba68 100644 --- a/src/topicgate/gui/components/diagnostic_profile_editor.py +++ b/src/topicgate/gui/components/diagnostic_profile_editor.py @@ -3,8 +3,6 @@ from dataclasses import replace from uuid import UUID -from topicgate.gui.icons import IconName, icon - from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QComboBox, QDialog, QFormLayout, QHBoxLayout, QLabel, QLineEdit, @@ -32,10 +30,8 @@ def __init__(self, editor, broker_id: UUID, parent: QWidget | None = None) -> No self._profiles.setObjectName("diagnosticProfileSelector") self._profiles.currentIndexChanged.connect(self._select_profile) self._new = QPushButton("Create") - self._new.setIcon(icon(IconName.CREATE)) self._copy = QPushButton("Copy") self._delete = QPushButton("Delete") - self._delete.setIcon(icon(IconName.DELETE)) self._new.clicked.connect(self._create) self._copy.clicked.connect(self._copy_profile) self._delete.clicked.connect(self._delete_profile) diff --git a/src/topicgate/gui/components/event_history_widget.py b/src/topicgate/gui/components/event_history_widget.py index a828792..521d5a3 100644 --- a/src/topicgate/gui/components/event_history_widget.py +++ b/src/topicgate/gui/components/event_history_widget.py @@ -7,35 +7,27 @@ from PySide6.QtWidgets import ( QCheckBox, QComboBox, QDateTimeEdit, QFormLayout, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPlainTextEdit, QPushButton, QSpinBox, QTableWidget, - QTableWidgetItem, QWidget, + QTableWidgetItem, QVBoxLayout, QWidget, ) -from topicgate.gui.components.workspace_pane import WorkspacePane - if TYPE_CHECKING: from topicgate.gui.main_view_model import MainViewModel -class EventHistoryWidget(WorkspacePane): +class EventHistoryWidget(QWidget): query_requested = Signal(object, str, object, object, object, int) - recording_status_requested = Signal(object) - recording_requested = Signal(object, bool) def __init__(self, view_model: MainViewModel, parent: QWidget | None = None) -> None: - super().__init__("Message history", minimum_hint_width=320) - if parent is not None: - self.setParent(parent) + super().__init__(parent) self._view_model = view_model - self._advanced_mode = True - self._recording_pending = False - self._recording_loading = False - layout = self.content_layout - self.setToolTip( - "Message receipts saved while recording was enabled, oldest first. " - "Earlier gaps cannot be recovered. Health failure history and latest stored values are separate." + layout = QVBoxLayout(self) + description = QLabel( + "Individual TopicGate-observed receipts, oldest first. This is not authoritative " + "broker history. Enable recording per broker in History settings." ) - form = self._form = QFormLayout() - form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) + description.setWordWrap(True) + layout.addWidget(description) + form = QFormLayout() self.broker = QComboBox() self.broker.setObjectName("eventHistoryBroker") self.broker.setAccessibleName("Event history broker") @@ -44,6 +36,7 @@ def __init__(self, view_model: MainViewModel, parent: QWidget | None = None) -> self.topic_filter = QLineEdit("#") self.topic_filter.setObjectName("eventHistoryTopicFilter") self.topic_filter.setAccessibleName("Event history MQTT topic filter") + form.addRow("Broker", self.broker) form.addRow("MQTT topic filter", self.topic_filter) self.after_enabled, self.after = self._time_filter(form, "After") self.before_enabled, self.before = self._time_filter(form, "Before") @@ -53,44 +46,20 @@ def __init__(self, view_model: MainViewModel, parent: QWidget | None = None) -> self.limit.setObjectName("eventHistoryLimit") self.limit.setAccessibleName("Event history page size") form.addRow("Events per page", self.limit) - self.recording_status = QLabel("Loading recording status…") - self.recording_status.setObjectName("eventHistoryRecordingStatus") - self.recording_status.setTextFormat(Qt.TextFormat.PlainText) - self.recording_status.setWordWrap(True) - recording_actions = QHBoxLayout() - self.enable_recording = QCheckBox("Record messages") - self.enable_recording.setObjectName("enableEventRecording") - self.enable_recording.setEnabled(False) - self.enable_recording.setToolTip( - "Record future receipts for this broker. Turning recording off keeps saved history." - ) - self.enable_recording.clicked.connect(self._request_recording) - self.reload_recording = QPushButton("Retry") - self.reload_recording.clicked.connect(self.request_recording_status) - broker_row = QHBoxLayout() - broker_row.addWidget(QLabel("Broker")) - broker_row.addWidget(self.broker, 1) - layout.addLayout(broker_row) - recording_actions.addWidget(self.enable_recording) - recording_actions.addWidget(self.recording_status, 1) - recording_actions.addWidget(self.reload_recording) - recording_actions.addStretch() - layout.addLayout(recording_actions) layout.addLayout(form) actions = QHBoxLayout() self.search = QPushButton("Search") - self.search.setProperty("primary", True) - self.search.setToolTip("Start a new search, including newly saved messages.") + self.refresh = QPushButton("Refresh snapshot") self.next_page = QPushButton("Next page") self.next_page.setObjectName("eventHistoryNextPage") self.next_page.setEnabled(False) self.search.clicked.connect(lambda: self._request(False)) + self.refresh.clicked.connect(lambda: self._request(False)) self.next_page.clicked.connect(lambda: self._request(True)) - for button in (self.search, self.next_page): + for button in (self.search, self.refresh, self.next_page): actions.addWidget(button) - actions.addStretch() layout.addLayout(actions) - self.status = QLabel("Search saved message history.") + self.status = QLabel("Search to read committed event history.") self.status.setObjectName("eventHistoryStatus") self.status.setTextFormat(Qt.TextFormat.PlainText) self.status.setWordWrap(True) @@ -100,7 +69,6 @@ def __init__(self, view_model: MainViewModel, parent: QWidget | None = None) -> self.limitations.setAccessibleName("Event history limitations") self.limitations.setReadOnly(True) self.limitations.setMaximumHeight(90) - self.limitations.setVisible(False) layout.addWidget(self.limitations) self.results = QTableWidget(0, 5) self.results.setObjectName("eventHistoryResults") @@ -124,71 +92,10 @@ def __init__(self, view_model: MainViewModel, parent: QWidget | None = None) -> self.limit.valueChanged): signal.connect(view_model.invalidate_event_history) view_model.event_history_changed.connect(self.render) - view_model.history_settings_changed.connect(self._recording_loaded) - view_model.operation_state_changed.connect(self.render_recording) - self.broker.currentIndexChanged.connect(self.request_recording_status) - self.broker.setCurrentIndex(max(0, self.broker.findData(view_model.active_broker_profile.id))) - self.render_recording() - - def select_workspace_broker(self) -> None: - broker_id = self._view_model.active_broker_profile.id - previous = self.broker.currentData() - self.broker.blockSignals(True) - self.broker.clear() - for profile in self._view_model.broker_profiles: - self.broker.addItem(profile.name, profile.id) - self.broker.setCurrentIndex(self.broker.findData(broker_id)) - self.broker.blockSignals(False) - if previous != broker_id: - self._view_model.invalidate_event_history() - self.request_recording_status() - - def _request_recording(self, enabled: bool) -> None: - self._recording_pending = True - self.enable_recording.setEnabled(False) - self.recording_requested.emit(self.broker.currentData(), enabled) - self.render_recording() - - def _recording_loaded(self) -> None: - if self._view_model.history_settings_broker == self.broker.currentData(): - self._recording_loading = False - self._recording_pending = False - self.render_recording() - - def request_recording_status(self) -> None: - self._recording_loading = True - self.render_recording() - self.recording_status_requested.emit(self.broker.currentData()) - - 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 - busy = vm.is_busy("history-settings") or self._recording_pending - error = vm.history_settings_error if vm.history_settings_broker == broker else None - 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) - self.reload_recording.setEnabled(not busy) - self.broker.setEnabled(not busy) - if self._recording_loading: - message = "Loading recording status…" - elif error: - message = error - elif busy: - message = "Applying recording settings…" - elif known: - message = ( - f"Recording {'enabled' if status.enabled else 'disabled'}" - ) - else: - message = "Recording status unavailable" - self.recording_status.setText(message) @staticmethod def _time_filter(form: QFormLayout, title: str) -> tuple[QCheckBox, QDateTimeEdit]: - enabled = QCheckBox(f"Received {title.lower()}") + enabled = QCheckBox(f"Use {title.lower()} bound") editor = QDateTimeEdit(QDateTime.currentDateTimeUtc()) editor.setDisplayFormat("yyyy-MM-dd HH:mm:ss 'UTC'") editor.setCalendarPopup(True) @@ -217,6 +124,7 @@ def render(self) -> None: vm = self._view_model page = vm.event_history_result self.search.setEnabled(not vm.event_history_busy) + self.refresh.setEnabled(not vm.event_history_busy) self.next_page.setEnabled(not vm.event_history_busy and page is not None and page.next_cursor is not None) self.results.setRowCount(0 if page is None else len(page.events)) self.payload.clear() @@ -231,18 +139,23 @@ def render(self) -> None: if column == 0: item.setData(Qt.ItemDataRole.UserRole, event) self.results.setItem(index, column, item) - self._render_page_status() + status = page.recording + self.status.setText( + f"{len(page.events)} events on this page · committed snapshot · " + f"recording {'enabled' if status.enabled else 'disabled'} · " + f"pending {status.pending} · dropped {status.dropped} · failed {status.failed}. " + f"Oldest retained: {page.usage.oldest_received_at or 'none'}." + ) self.limitations.setPlainText("\n".join( item.replace("follow next_cursor", "use Next page") for item in page.limitations )) else: - self.status.setText("Search saved message history.") + self.status.setText("Search to read committed event history.") self.limitations.clear() if vm.event_history_busy: - self.status.setText("Loading saved event history…") + self.status.setText("Loading committed event history…") elif vm.event_history_error: self.status.setText(vm.event_history_error) - self.limitations.setVisible(bool(self.limitations.toPlainText())) def _inspect(self) -> None: item = self.results.item(self.results.currentRow(), 0) @@ -250,33 +163,8 @@ def _inspect(self) -> None: return event = item.data(Qt.ItemDataRole.UserRole) payload = event.payload_text if event.payload_text is not None else f"base64: {event.payload_base64}" - details = ( + self.payload.setPlainText( f"Observation {event.observation_id} · {event.provenance.replace('_', ' ')} · QoS {event.qos} · " f"retain {event.retain}\nStorage truncated: {event.is_truncated}; " - f"rendering truncated: {event.rendering_truncated}\n\n" - ) if self._advanced_mode else ( - "Partial payload (truncated).\n\n" if event.is_truncated or event.rendering_truncated else "" - ) - self.payload.setPlainText(details + payload) - - def set_advanced_mode(self, advanced: bool) -> None: - self._advanced_mode = advanced - self._form.setRowVisible(self.limit, advanced) - for column in (2, 3): - self.results.setColumnHidden(column, not advanced) - self._render_page_status() - self._inspect() - - def _render_page_status(self) -> None: - page = self._view_model.event_history_result - if page is None or self._view_model.event_history_busy or self._view_model.event_history_error: - return - status = page.recording - detail = ( - f" · pending {status.pending} · dropped {status.dropped} · failed {status.failed}" - if self._advanced_mode else "" - ) - self.status.setText( - f"{len(page.events)} saved events on this page{detail}. " - f"Oldest retained: {page.usage.oldest_received_at or 'none'}." + f"rendering truncated: {event.rendering_truncated}\n\n{payload}" ) diff --git a/src/topicgate/gui/components/expectation_editor.py b/src/topicgate/gui/components/expectation_editor.py index 1f8f8a0..50f1fdf 100644 --- a/src/topicgate/gui/components/expectation_editor.py +++ b/src/topicgate/gui/components/expectation_editor.py @@ -1,8 +1,6 @@ from base64 import b64encode from uuid import UUID -from topicgate.gui.icons import IconName, icon - from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QCheckBox, @@ -51,9 +49,6 @@ def __init__( if target_kind not in {"broker", "topic"}: raise ValueError("target_kind must be 'broker' or 'topic'") self._view_model = view_model - self._draft_baseline = None - self._draft_context = None - self._advanced_mode = True self._target_kind = target_kind self._selected_id: UUID | None = None self._editing = target_kind == "topic" @@ -65,7 +60,6 @@ def __init__( self._context = QLabel() self._context.setObjectName("expectationContext") self._context.setWordWrap(True) - self._context.setTextFormat(Qt.TextFormat.PlainText) layout.addWidget(self._context) self._result_summary = QLabel() self._result_summary.setObjectName("expectationResultSummary") @@ -93,17 +87,11 @@ def __init__( QHeaderView.ResizeMode.ResizeToContents, ) self._table.cellClicked.connect(self._select_row) - self._table.setMaximumHeight(150) - if target_kind == "topic": - self._table.setColumnHidden(1, True) - self._table.setColumnHidden(2, True) - layout.addWidget(self._table) + layout.addWidget(self._table, 1) self._form_container = QWidget() self._form_container.setObjectName("expectationEditingControls") form = QFormLayout(self._form_container) - form.setFormAlignment(Qt.AlignmentFlag.AlignTop) - form.setRowWrapPolicy(QFormLayout.RowWrapPolicy.WrapLongRows) self._form = form self._name = QLineEdit() self._name.setObjectName("expectationName") @@ -189,21 +177,14 @@ def __init__( self._revision = QLabel() self._revision.setObjectName("expectationRevision") form.addRow("Details", self._revision) - self._options_summary = QLabel() - self._options_summary.setObjectName("expectationOptionsSummary") - self._options_summary.setWordWrap(True) - form.addRow(self._options_summary) layout.addWidget(self._form_container) self._form_container.setVisible(self._editing) buttons = QHBoxLayout() self._new_button = QPushButton("Add expectation") - self._new_button.setIcon(icon(IconName.CREATE)) self._new_button.setObjectName("addExpectationButton") self._delete_button = QPushButton("Delete") - self._delete_button.setIcon(icon(IconName.DELETE)) self._delete_button.setObjectName("deleteExpectationButton") - self._delete_button.setProperty("danger", True) self._save_button = QPushButton("Save") self._save_button.setObjectName("saveExpectationButton") self._new_button.clicked.connect(self._new) @@ -214,61 +195,16 @@ def __init__( buttons.addWidget(self._delete_button) buttons.addWidget(self._save_button) layout.addLayout(buttons) - self._feedback = QLabel() - self._feedback.setObjectName("expectationSaveFeedback") - self._feedback.setTextFormat(Qt.TextFormat.PlainText) - self._feedback.setWordWrap(True) - layout.addWidget(self._feedback) - layout.addStretch(1) self._view_model.health_changed.connect(self.render) self.render() - def _form_state(self) -> tuple: - return ( - self._name.text(), self._description.text(), - self._enabled.isChecked(), self._log_action.isChecked(), - self._store_action.isChecked(), self._condition_kind.currentIndex(), - self._expected.currentText(), self._expected_values.toPlainText(), - self._encoding.currentIndex(), - ) - - @property - def has_unsaved_edits(self) -> bool: - return self._draft_baseline is not None and self._form_state() != self._draft_baseline - - def _remember_draft(self) -> None: - self._draft_baseline = self._form_state() - self._draft_context = ( - self._view_model.active_broker_profile.id, - self._view_model.topic if self._target_kind == "topic" else "", - ) - self.set_advanced_mode(self._advanced_mode) - - def set_advanced_mode(self, advanced: bool) -> None: - self._advanced_mode = advanced - for widget in (self._log_action, self._store_action, self._revision, self._description): - self._form.setRowVisible(widget, advanced) - actions = [] - if self._log_action.isChecked(): - actions.append("log transitions") - if self._store_action.isChecked(): - actions.append("save failure history") - self._options_summary.setText("On changes: " + (", ".join(actions) or "no actions") + ".") - self._options_summary.setVisible(not advanced and len(actions) != 2) - def render(self) -> None: - context = ( - self._view_model.active_broker_profile.id, - self._view_model.topic if self._target_kind == "topic" else "", - ) - if self.has_unsaved_edits and self._draft_context == context: - return if self._target_kind == "topic": topic = self._view_model.topic available = bool(topic) and "+" not in topic and "#" not in topic self._context.setText( - f"{self._view_model.active_broker_profile.name} · Topic: {topic}" + f"Expectations for {topic}" if available else "Select an exact topic to configure expectations." ) @@ -322,7 +258,6 @@ def render(self) -> None: self._delete_button.setEnabled(False) if self._target_kind == "broker": self._form_container.setVisible(self._editing) - self._remember_draft() def _new(self) -> None: self._editing = True @@ -343,10 +278,6 @@ def _new(self) -> None: self._expected.setCurrentText(ConnectionStatus.CONNECTED.value) self._delete_button.setEnabled(False) self._name.setFocus(Qt.FocusReason.OtherFocusReason) - self._remember_draft() - - def start_new(self) -> None: - self._new() def _select_row(self, row: int, _column: int) -> None: if 0 <= row < len(self._expectations): @@ -371,7 +302,6 @@ def _load(self, expectation: HealthExpectation) -> None: self._set_expected_values(values) self._revision.setText(f"Revision {expectation.revision}") self._delete_button.setEnabled(True) - self._remember_draft() def select_expectation(self, expectation_id: object) -> None: """Select an expectation from another health presentation.""" @@ -406,12 +336,9 @@ def _save(self) -> None: QMessageBox.warning(self, "Invalid expectation", str(error)) return self._selected_id = None - self._draft_baseline = None self._editing = self._target_kind == "topic" self.render() - self._feedback.setText("Expectation saved for this " + self._target_kind + ".") - def _delete(self) -> None: if self._selected_id is None: return diff --git a/src/topicgate/gui/components/health_inspector.py b/src/topicgate/gui/components/health_inspector.py index 6fbc3db..a206137 100644 --- a/src/topicgate/gui/components/health_inspector.py +++ b/src/topicgate/gui/components/health_inspector.py @@ -1,7 +1,5 @@ from datetime import timezone -from topicgate.gui.icons import IconName, icon - from PySide6.QtCore import QDateTime, Qt, Signal from PySide6.QtWidgets import ( QCheckBox, @@ -14,7 +12,6 @@ QMessageBox, QPlainTextEdit, QPushButton, - QScrollArea, QTableWidget, QTableWidgetItem, QTabWidget, @@ -38,7 +35,6 @@ def __init__(self, view_model: MainViewModel) -> None: super().__init__("Health", minimum_hint_width=320) self.setObjectName("healthInspector") self._view_model = view_model - self._advanced_mode = True self._selected_topic = "" self._selected_expectation = None @@ -47,12 +43,8 @@ def __init__(self, view_model: MainViewModel) -> None: self._tabs.tabBar().setObjectName("healthTabs") self._tabs.addTab(self._overview_page(), "Overview") self._broker_expectations = ExpectationEditor(view_model, "broker") - expectations_scroll = QScrollArea() - expectations_scroll.setWidgetResizable(True) - expectations_scroll.setFrameShape(QScrollArea.Shape.NoFrame) - expectations_scroll.setWidget(self._expectations_page()) - self._tabs.addTab(expectations_scroll, "Expectations") - self._tabs.addTab(self._history_page(), "Failure history") + self._tabs.addTab(self._broker_expectations, "Expectations") + self._tabs.addTab(self._history_page(), "History") self._tabs.addTab(self._changes_page(), "Changes") self._tabs.currentChanged.connect(self._tab_changed) self.content_layout.addWidget(self._tabs, 1) @@ -61,95 +53,6 @@ def __init__(self, view_model: MainViewModel) -> None: self._view_model.configuration_changed.connect(self.render) self.render() - def set_advanced_mode(self, advanced: bool) -> None: - self._advanced_mode = advanced - self._tabs.blockSignals(True) - if not advanced and self._tabs.currentIndex() == 3: - self._tabs.setCurrentIndex(0) - self._tabs.setTabVisible(3, advanced) - self._tabs.setTabEnabled(3, advanced) - self._tabs.blockSignals(False) - self._delete_history_button.setVisible(advanced) - self._history_table.setColumnHidden(4, not advanced) - self._broker_expectations.set_advanced_mode(advanced) - - def _expectations_page(self) -> QWidget: - page = QWidget() - layout = QVBoxLayout(page) - self._expectation_scope = QComboBox() - self._expectation_scope.setObjectName("healthExpectationScope") - self._expectation_scope.addItems(["All expectations", "Broker expectations", "Topic expectations"]) - self._expectation_scope.currentIndexChanged.connect(self._render_expectation_directory) - layout.addWidget(self._expectation_scope) - self._expectation_directory = QTableWidget(0, 2) - self._expectation_directory.setObjectName("healthExpectationDirectory") - self._expectation_directory.setHorizontalHeaderLabels(["Name", "Scope / target"]) - self._expectation_directory.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) - self._expectation_directory.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) - self._expectation_directory.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - self._expectation_directory.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) - self._expectation_directory.itemSelectionChanged.connect( - lambda: self._open_rule.setEnabled(self._expectation_directory.currentRow() >= 0) - ) - self._expectation_directory.cellDoubleClicked.connect(lambda *_: self._open_directory_rule()) - layout.addWidget(self._expectation_directory, 1) - self._directory_status = QLabel() - self._directory_status.setTextFormat(Qt.TextFormat.PlainText) - self._directory_status.setWordWrap(True) - layout.addWidget(self._directory_status) - self._open_rule = QPushButton("Edit selected expectation") - self._open_rule.setIcon(icon(IconName.EDIT)) - self._open_rule.setEnabled(False) - self._open_rule.clicked.connect(self._open_directory_rule) - layout.addWidget(self._open_rule) - add_broker = QPushButton("Add broker expectation") - add_broker.setIcon(icon(IconName.CREATE)) - add_broker.clicked.connect(self._add_broker_expectation) - layout.addWidget(add_broker) - layout.addWidget(self._broker_expectations, 1) - self._broker_expectations.setVisible(False) - return page - - def _add_broker_expectation(self) -> None: - self._broker_expectations.setVisible(True) - self._broker_expectations.start_new() - - def _render_expectation_directory(self) -> None: - from topicgate.core.models.health import TopicTarget - - scope = self._expectation_scope.currentIndex() - selected_cell = self._expectation_directory.item(self._expectation_directory.currentRow(), 0) - selected = None if selected_cell is None else selected_cell.data(Qt.ItemDataRole.UserRole) - rows = tuple( - item for item in self._view_model.all_expectations - if scope == 0 or (scope == 2) == isinstance(item.target, TopicTarget) - ) - self._expectation_directory.setRowCount(0) - self._expectation_directory.setRowCount(len(rows)) - for row, expectation in enumerate(rows): - topic = getattr(expectation.target, "topic", "") - cell = QTableWidgetItem(expectation.name) - cell.setData(Qt.ItemDataRole.UserRole, (topic, expectation.expectation_id)) - self._expectation_directory.setItem(row, 0, cell) - self._expectation_directory.setItem(row, 1, QTableWidgetItem("Topic: " + topic if topic else "Broker connection")) - self._directory_status.setText( - f"{self._view_model.active_broker_profile.name} · {len(rows)} configured expectations in this scope. " - "Select a rule to edit it. Topic rules open with their topic selected." - ) - self._open_rule.setEnabled(False) - for row in range(self._expectation_directory.rowCount()): - if self._expectation_directory.item(row, 0).data(Qt.ItemDataRole.UserRole) == selected: - self._expectation_directory.selectRow(row) - break - if scope == 2: - self._broker_expectations.setVisible(False) - - def _open_directory_rule(self) -> None: - cell = self._expectation_directory.item(self._expectation_directory.currentRow(), 0) - if cell is not None: - topic, expectation_id = cell.data(Qt.ItemDataRole.UserRole) - self.expectation_edit_requested.emit(topic, expectation_id) - def _overview_page(self) -> QWidget: page = QWidget() layout = QVBoxLayout(page) @@ -184,10 +87,8 @@ def _overview_page(self) -> QWidget: self._open_topic = QPushButton("Open topic") self._open_topic.setObjectName("openHealthTopicButton") self._edit_expectation = QPushButton("Edit expectation") - self._edit_expectation.setIcon(icon(IconName.EDIT)) self._edit_expectation.setObjectName("editHealthExpectationButton") self._remove_expectation = QPushButton("Remove") - self._remove_expectation.setIcon(icon(IconName.DELETE)) self._remove_expectation.setObjectName("removeHealthExpectationButton") self._remove_expectation.setProperty("danger", True) self._view_history = QPushButton("View failure history") @@ -218,11 +119,10 @@ def _history_page(self) -> QWidget: self._history_status = QComboBox() self._history_status.setObjectName("healthHistoryStatus") self._history_status.addItems(["all", "active", "recovered"]) - self._query_button = QPushButton("Search") + self._query_button = QPushButton("Apply") self._query_button.setObjectName("queryHealthHistoryButton") self._query_button.clicked.connect(self.query_history) self._delete_history_button = QPushButton("Delete selected") - self._delete_history_button.setIcon(icon(IconName.DELETE)) self._delete_history_button.setObjectName("deleteHealthHistoryButton") self._delete_history_button.setProperty("danger", True) self._delete_history_button.setEnabled(False) @@ -326,7 +226,6 @@ def load_more_history(self) -> None: self._query_history(cursor) def render(self) -> None: - self._render_expectation_directory() summary = self._view_model.health_summary counts = f" — {summary.counts}" if summary.counts else "" self._status.setText( @@ -379,8 +278,6 @@ def show_history(self, topic: str = "") -> None: def select_broker_expectation(self, expectation_id: object) -> None: self._tabs.setCurrentIndex(1) - self._expectation_scope.setCurrentIndex(1) - self._broker_expectations.setVisible(True) self._broker_expectations.select_expectation(expectation_id) def _render_health_rows(self, table: QTableWidget, rows: tuple) -> None: @@ -425,8 +322,7 @@ def _render_delta(self, report) -> None: for row, event in enumerate(events): values = ( str(event.kind).replace("_", " ").title(), - next((item.name for item in self._view_model.all_expectations - if str(event.rule_id) in (str(item.expectation_id), item.rule_id)), str(event.rule_id)), + event.rule_id, event.matched_topic or "broker", self._transition_label( event.previous_status, @@ -575,9 +471,6 @@ def _query_history(self, cursor: int | None) -> None: self._history_message.setText(f"Unable to load failure history: {error}") def _tab_changed(self, index: int) -> None: - if index == 3 and not self._advanced_mode: - self._tabs.setCurrentIndex(0) - return if index == 2: self.query_history() diff --git a/src/topicgate/gui/components/history_settings_widget.py b/src/topicgate/gui/components/history_settings_widget.py index 7dcc54a..5ebc1f7 100644 --- a/src/topicgate/gui/components/history_settings_widget.py +++ b/src/topicgate/gui/components/history_settings_widget.py @@ -4,16 +4,11 @@ from PySide6.QtCore import Signal, Qt from PySide6.QtWidgets import ( - QCheckBox, QComboBox, QFormLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout, + QCheckBox, QComboBox, QFormLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget, ) from topicgate.core.models.history_retention import HistoryRetentionPolicy -from topicgate.gui.components.quantity_editor import QuantityEditor -from topicgate.presentation.retention_presentation import ( - AgeUnit, ByteUnit, display_age_value, display_byte_value, - exact_age_seconds, exact_byte_value, -) if TYPE_CHECKING: from topicgate.gui.main_view_model import MainViewModel @@ -51,56 +46,27 @@ def __init__(self, view_model: MainViewModel, parent: QWidget | None = None) -> description.setWordWrap(True) layout.addWidget(description) limits = QFormLayout() - advanced_limits = QFormLayout() - self.advanced_content = QWidget() - self.advanced_content.setLayout(advanced_limits) self.fields: dict[str, QLineEdit] = {} - self.quantities = {} - self.unlimited = {} for name, label in ( - ("max_age_seconds", "Maximum age"), + ("max_age_seconds", "Maximum age (seconds; blank = unlimited)"), ("max_events_per_broker", "Maximum events per broker"), - ("max_events_per_topic", "Maximum events per topic"), - ("max_payload_bytes", "Stored payload across all brokers"), + ("max_events_per_topic", "Maximum events per topic (blank = unlimited)"), + ("max_payload_bytes", "Global history payload budget (bytes)"), ("prune_batch_size", "Pruning batch size (1–500)"), ("prune_interval_seconds", "Idle pruning interval (seconds)"), ): editor = QLineEdit() - field = editor - if name in ("max_age_seconds", "max_payload_bytes"): - units = AgeUnit if name == "max_age_seconds" else ByteUnit - field = QuantityEditor(tuple(unit.value for unit in units), f"history_{name}") - self.quantities[name] = field - editor = field.value - field.changed.connect(self._validate) editor.setObjectName(f"history_{name}") editor.setAccessibleName(label) editor.textChanged.connect(self._validate) self.fields[name] = editor - if name in ("max_age_seconds", "max_events_per_topic"): - row = QWidget() - row_layout = QHBoxLayout(row) - row_layout.setContentsMargins(0, 0, 0, 0) - unlimited = QCheckBox("Unlimited") - unlimited.setObjectName(f"history_{name}Unlimited") - unlimited.toggled.connect(lambda checked, field=field: field.setEnabled(not checked)) - unlimited.toggled.connect(self._validate) - self.unlimited[name] = unlimited - row_layout.addWidget(unlimited) - row_layout.addWidget(field, 1) - field = row - target_form = advanced_limits if name.startswith("prune_") else limits - target_form.addRow(label, field) + limits.addRow(label, editor) layout.addLayout(limits) - layout.addWidget(self.advanced_content) self.error = QLabel() self.error.setObjectName("historySettingsError") self.error.setTextFormat(Qt.TextFormat.PlainText) self.error.setWordWrap(True) self.error.setStyleSheet("color: #b91c1c;") - self.feedback = QLabel() - self.feedback.setObjectName("historySettingsFeedback") - self.feedback.setWordWrap(True) self.summary = QLabel("Load history settings to inspect recording and storage usage.") self.summary.setObjectName("historyUsageSummary") self.summary.setTextFormat(Qt.TextFormat.PlainText) @@ -112,54 +78,39 @@ def __init__(self, view_model: MainViewModel, parent: QWidget | None = None) -> self.reload = QPushButton("Reload settings and usage") self.reload.clicked.connect(lambda: self.load_requested.emit(self.broker.currentData())) layout.addWidget(self.error) - layout.addWidget(self.feedback) layout.addWidget(self.summary) layout.addWidget(self.save) layout.addWidget(self.reload) layout.addStretch() self.broker.currentIndexChanged.connect(self._broker_changed) view_model.history_settings_changed.connect(self.render) - view_model.operation_state_changed.connect(self._validate) - self.broker.setCurrentIndex(max(0, self.broker.findData(view_model.active_broker_profile.id))) def draft_policy(self) -> HistoryRetentionPolicy: values = {} for name, editor in self.fields.items(): value = editor.text().strip() - if name in self.unlimited and self.unlimited[name].isChecked(): + if not value and name in ("max_age_seconds", "max_events_per_topic"): values[name] = None else: try: values[name] = int(value) except ValueError as error: raise ValueError(f"{editor.accessibleName()}: enter a positive integer.") from error - if name == "max_age_seconds": - values[name] = exact_age_seconds(values[name], AgeUnit(self.quantities[name].unit.currentText())) - elif name == "max_payload_bytes": - values[name] = exact_byte_value(values[name], ByteUnit(self.quantities[name].unit.currentText())) return HistoryRetentionPolicy(**values) def render(self) -> None: vm = self._view_model if vm.history_settings_broker != self.broker.currentData(): return - self.feedback.setText(vm.history_settings_feedback) self._loaded = vm.history_policy is not None if vm.history_policy is not None: for name, editor in self.fields.items(): value = getattr(vm.history_policy, name) - if name in self.unlimited: - self.unlimited[name].setChecked(value is None) - if value is not None and name in self.quantities: - value, unit = display_age_value(value) if name == "max_age_seconds" else display_byte_value(value) - self.quantities[name].unit.setCurrentText(unit.value) editor.setText("" if value is None else str(value)) status = vm.history_recording_status usage = vm.history_usage - if status is not None and status.broker_id != self.broker.currentData(): - status, usage = None, None - self.enabled.setEnabled(self._loaded) - self.enabled.setChecked(status.enabled if status is not None else False) + if status is not None: + self.enabled.setChecked(status.enabled) if status is not None and usage is not None: self.summary.setText( f"Broker history: {usage.event_count:,} events; {usage.payload_bytes:,} payload bytes. " @@ -170,8 +121,6 @@ def render(self) -> None: f"Global evictions by limit: {usage.evictions}. " f"Enforcement pending: {'yes' if usage.enforcement_pending else 'no'}." ) - else: - self.summary.setText("History usage is unavailable for this broker. Reload to retry.") self._validate() if vm.history_settings_error: self.error.setText(vm.history_settings_error) @@ -186,24 +135,12 @@ def _validate(self) -> None: self.save.setEnabled(False) else: self.error.clear() - busy = self._view_model.is_busy("history-settings") - self.save.setEnabled(self._loaded and not busy) - self.save.setText("Applying…" if busy else "Apply history settings") - self.broker.setEnabled(not busy) - if self._view_model.history_settings_broker == self.broker.currentData(): - if self._view_model.history_settings_error: - self.error.setText(self._view_model.history_settings_error) + self.save.setEnabled(self._loaded) def _broker_changed(self) -> None: self._loaded = False self.save.setEnabled(False) - self.enabled.setEnabled(False) - self.feedback.clear() - self.summary.setText("Loading history settings for this broker…") self.load_requested.emit(self.broker.currentData()) - def request_load(self) -> None: - self._broker_changed() - def _save(self) -> None: self.save_requested.emit(self.broker.currentData(), self.enabled.isChecked(), self.draft_policy()) diff --git a/src/topicgate/gui/components/mcp_setup_dialog.py b/src/topicgate/gui/components/mcp_setup_dialog.py index be4ae12..329b62f 100644 --- a/src/topicgate/gui/components/mcp_setup_dialog.py +++ b/src/topicgate/gui/components/mcp_setup_dialog.py @@ -1,5 +1,3 @@ -from topicgate.gui.icons import IconName, icon - from PySide6.QtCore import Signal from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import ( @@ -138,7 +136,6 @@ def __init__( layout.addWidget(self._diagnostic_result) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) - buttons.button(QDialogButtonBox.StandardButton.Close).setIcon(icon(IconName.CLOSE)) buttons.rejected.connect(self.accept) layout.addWidget(buttons) self._render_configuration() diff --git a/src/topicgate/gui/components/observer_tree.py b/src/topicgate/gui/components/observer_tree.py index e333767..4fc36aa 100644 --- a/src/topicgate/gui/components/observer_tree.py +++ b/src/topicgate/gui/components/observer_tree.py @@ -2,6 +2,7 @@ from PySide6.QtGui import QStandardItem, QStandardItemModel from PySide6.QtWidgets import ( QHBoxLayout, + QFrame, QHeaderView, QLabel, QLineEdit, @@ -11,10 +12,8 @@ ) from topicgate.core.models.subscription import Subscription -from topicgate.app.models.broker_snapshot import BrokerSnapshot -from topicgate.presentation.snapshot_presentation import SnapshotQuery from topicgate.gui.components.workspace_pane import WorkspacePane -from topicgate.gui.icons import IconName, icon +from topicgate.gui.icons import delete_icon from topicgate.presentation.topic_presentation import TopicTreeNode TOPIC_ROLE = Qt.ItemDataRole.UserRole + 1 @@ -26,43 +25,28 @@ class ObserverTreePane(WorkspacePane): topic_selected = Signal(str) add_filter_requested = Signal() remove_filter_requested = Signal(object) + empty_state_action_requested = Signal(str) def __init__(self) -> None: super().__init__("Observer Tree") - heading_icon = QLabel() - heading_icon.setPixmap(icon(IconName.OBSERVER_TREE).pixmap(16, 16)) - self.header_layout.insertWidget(0, heading_icon) - self._advanced_mode = True - self._scope_context = None self._items: dict[str, QStandardItem] = {} self._rendering = False controls = QHBoxLayout() self._search_edit = QLineEdit() - self._search_edit.setPlaceholderText("Filter displayed topics…") + self._search_edit.setPlaceholderText("Search topics...") self._search_edit.setAccessibleName("Search observed topics") self._search_edit.setClearButtonEnabled(True) controls.addWidget(self._search_edit, 1) add_button = QToolButton() - add_button.setText("Add subscription") - add_button.setIcon(icon(IconName.CREATE)) - add_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + add_button.setText("+ Filter") add_button.setToolTip("Add an MQTT subscription filter") add_button.setAccessibleName("Add MQTT subscription filter") add_button.clicked.connect(self.add_filter_requested) controls.addWidget(add_button) self.content_layout.addLayout(controls) - self._scope = QLabel() - self._scope.setObjectName("observerDisplayScope") - self._scope.setTextFormat(Qt.TextFormat.PlainText) - self._scope.setWordWrap(True) - self._scope.setVisible(False) - self._search_status = QLabel() - self._search_status.setObjectName("observerSearchStatus") - self._search_status.setWordWrap(True) - self.content_layout.addWidget(self._search_status) self._model = QStandardItemModel(self) self._model.setHorizontalHeaderLabels(["Topic", "", "State"]) @@ -98,8 +82,24 @@ def __init__(self) -> None: self._tree.clicked.connect(self._topic_activated) self._tree.activated.connect(self._topic_activated) self._search_edit.textChanged.connect(self._proxy.setFilterFixedString) - self._search_edit.textChanged.connect(self._render_search_status) self.content_layout.addWidget(self._tree, 1) + self._empty_state = QFrame() + self._empty_state.setObjectName("observerEmptyState") + self._empty_state.setFrameShape(QFrame.Shape.StyledPanel) + empty_layout = QHBoxLayout(self._empty_state) + self._empty_state_text = QLabel() + self._empty_state_text.setObjectName("observerEmptyStateText") + self._empty_state_text.setWordWrap(True) + empty_layout.addWidget(self._empty_state_text, 1) + self._empty_state_action = QToolButton() + self._empty_state_action.setObjectName("observerEmptyStateAction") + self._empty_state_action.clicked.connect( + lambda: self.empty_state_action_requested.emit( + str(self._empty_state_action.property("action") or "") + ) + ) + empty_layout.addWidget(self._empty_state_action) + self.content_layout.addWidget(self._empty_state) def render( self, @@ -133,27 +133,48 @@ def render( finally: self._rendering = False - def render_scope( - self, query: SnapshotQuery, snapshot: BrokerSnapshot, subscription_count: int, + def render_empty_state( + self, + connection_status: str, + subscriptions: tuple[Subscription, ...], + query_is_filtered: bool, + has_cached_values: bool, + has_topics: bool, ) -> None: - self._scope_context = (query, snapshot, subscription_count) - self._scope.clear() - self._scope.setVisible(False) - self._render_search_status() - - def set_advanced_mode(self, advanced: bool) -> None: - self._advanced_mode = advanced - if self._scope_context is not None: - self.render_scope(*self._scope_context) - - def _render_search_status(self) -> None: - active = bool(self._search_edit.text()) - self._search_status.setText( - "No displayed topics match this text. Clear the search to restore the tree." - if active and self._proxy.rowCount() == 0 - else "Text filter active — snapshot counts above are before this text filter." - ) - self._search_status.setVisible(active) + """Explain why the workspace has no immediately useful live values.""" + if has_topics and not has_cached_values: + self._empty_state.setVisible(False) + return + if not subscriptions: + message, action, label = ( + "No subscriptions. Add a filter to observe values.", + "add-filter", + "Add filter", + ) + elif connection_status == "disconnected": + message, action, label = ( + "Broker disconnected. Stored values may be stale.", + "connect", + "Connect", + ) + elif query_is_filtered and not has_topics: + message, action, label = ( + "No values match the current snapshot filters. Clear filters or capture a new snapshot.", + "clear-filters", + "Clear filters", + ) + else: + message, action, label = ( + "No values observed yet. Capture a snapshot after publishers send messages.", + "observe", + "Reconnect & observe", + ) + self._empty_state_text.setText(message) + self._empty_state_text.setAccessibleName(message) + self._empty_state_action.setText(label) + self._empty_state_action.setAccessibleName(label) + self._empty_state_action.setProperty("action", action) + self._empty_state.setVisible(True) def render_tree( self, @@ -174,13 +195,6 @@ def append(items: tuple[TopicTreeNode, ...]) -> None: def apply_node_presentation(items: tuple[TopicTreeNode, ...]) -> None: for node in items: item = self._items[node.path] - if node.is_subscription: - item.setText(f"Subscription: {node.label}") - item.setToolTip( - f"{node.path}\n" - + ("MQTT subscription. " if node.is_subscription else "") - + ("Observed value in this snapshot." if node.is_observed else "No observed value on this row.") - ) item.setSelectable(node.selectable) item.setData(node.path if node.selectable else None, TOPIC_ROLE) if node.badges: @@ -207,6 +221,11 @@ def collapse_all(self) -> None: def focus_search(self) -> None: self._search_edit.setFocus(Qt.FocusReason.ShortcutFocusReason) + def set_connection_busy(self, busy: bool) -> None: + action = str(self._empty_state_action.property("action") or "") + if action in {"connect", "observe"}: + self._empty_state_action.setEnabled(not busy) + def _add_topic(self, topic: str) -> None: parent = self._model.invisibleRootItem() partial_path: list[str] = [] @@ -240,7 +259,7 @@ def _add_remove_button(self, subscription: Subscription) -> None: button.setObjectName("removeSubscriptionButton") button.setFixedSize(24, 18) button.setIconSize(QSize(12, 12)) - button.setIcon(icon(IconName.DELETE)) + button.setIcon(delete_icon()) button.setStyleSheet( "QToolButton {" " background-color: transparent;" diff --git a/src/topicgate/gui/components/onboarding_panel.py b/src/topicgate/gui/components/onboarding_panel.py index 516f881..2692c5b 100644 --- a/src/topicgate/gui/components/onboarding_panel.py +++ b/src/topicgate/gui/components/onboarding_panel.py @@ -1,5 +1,3 @@ -from topicgate.gui.icons import IconName, icon - from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout @@ -34,7 +32,6 @@ def __init__(self) -> None: title_row.addWidget(title) title_row.addStretch(1) dismiss = QPushButton("Dismiss") - dismiss.setIcon(icon(IconName.CLOSE)) dismiss.setObjectName("dismissFirstRunChecklistButton") dismiss.setAccessibleName("Dismiss first-run checklist") dismiss.clicked.connect(self.dismissed.emit) diff --git a/src/topicgate/gui/components/publish_pane.py b/src/topicgate/gui/components/publish_pane.py index 4c9c8ed..04a522e 100644 --- a/src/topicgate/gui/components/publish_pane.py +++ b/src/topicgate/gui/components/publish_pane.py @@ -38,10 +38,8 @@ def __init__(self) -> None: self._payload = QPlainTextEdit() self._payload.setObjectName("publishPayload") self._payload.setPlaceholderText("Message payload") - self._payload.setAccessibleName("Message payload") - self._payload.setMinimumHeight(160) - self._payload.setTabChangesFocus(True) - layout.addWidget(self._payload, 1) + self._payload.setMaximumHeight(120) + layout.addWidget(self._payload) self._publish = QPushButton("Publish message") self._publish.setObjectName("publishButton") self._publish.setProperty("primary", True) diff --git a/src/topicgate/gui/components/quantity_editor.py b/src/topicgate/gui/components/quantity_editor.py deleted file mode 100644 index a5ca905..0000000 --- a/src/topicgate/gui/components/quantity_editor.py +++ /dev/null @@ -1,23 +0,0 @@ -from PySide6.QtCore import Signal -from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLineEdit, QWidget - - -class QuantityEditor(QWidget): - changed = Signal() - - def __init__(self, units: tuple[str, ...], object_name: str) -> None: - super().__init__() - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - self.value = QLineEdit() - self.value.setObjectName(f"{object_name}Value") - self.value.setAccessibleName(f"{object_name} value") - self.unit = QComboBox() - self.unit.setObjectName(f"{object_name}Unit") - self.unit.setAccessibleName(f"{object_name} unit") - self.unit.addItems(units) - layout.addWidget(self.value, 1) - layout.addWidget(self.unit) - self.value.textChanged.connect(lambda _text: self.changed.emit()) - self.unit.currentIndexChanged.connect(lambda _index: self.changed.emit()) - diff --git a/src/topicgate/gui/components/snapshot_panel.py b/src/topicgate/gui/components/snapshot_panel.py index 0db6012..72af042 100644 --- a/src/topicgate/gui/components/snapshot_panel.py +++ b/src/topicgate/gui/components/snapshot_panel.py @@ -8,6 +8,7 @@ QLineEdit, QPushButton, QSpinBox, + QToolButton, QVBoxLayout, QWidget, ) @@ -30,12 +31,21 @@ class SnapshotPanel(WorkspacePane): reset_requested = Signal() reconnect_observe_requested = Signal(object) validation_failed = Signal(str) + advanced_changed = Signal(bool) def __init__(self) -> None: super().__init__("Broker snapshot") self.setObjectName("snapshotPanel") self.setMinimumWidth(0) + self._advanced_button = QToolButton() + self._advanced_button.setObjectName("snapshotAdvancedButton") + self._advanced_button.setText("Advanced") + self._advanced_button.setCheckable(True) + self._advanced_button.setAccessibleName( + "Show advanced snapshot details" + ) + self._advanced_button.toggled.connect(self._set_advanced_visible) self._summary_labels = { name: self._summary_label(object_name) for name, object_name in ( @@ -47,6 +57,7 @@ def __init__(self) -> None: } self.header_layout.addWidget(self._summary_labels["connection"]) self.header_layout.addWidget(self._summary_labels["completeness"]) + self.header_layout.addWidget(self._advanced_button) secondary_summary = QHBoxLayout() secondary_summary.setSpacing(12) @@ -179,20 +190,18 @@ def __init__(self) -> None: clear_button, observe_button, ) - self._rendered_query = None + self._rendered_query = SnapshotQuery() self._omitted_count = 0 self.render_query(SnapshotQuery()) self.render_connection_status("disconnected") self._summary_labels["returned"].setText("Returned 0") self._summary_labels["dropped"].setText("Dropped 0") self._summary_labels["completeness"].setText("Limited") + self._set_advanced_visible(False) @property - def has_unsaved_edits(self) -> bool: - try: - return self.query != self._rendered_query - except ValueError: - return True + def is_advanced_visible(self) -> bool: + return self._advanced_button.isChecked() @property def query(self) -> SnapshotQuery: @@ -210,9 +219,10 @@ def query(self) -> SnapshotQuery: payload_limit_bytes=self._payload_limit.value(), ) + def set_advanced_visible(self, visible: bool) -> None: + self._advanced_button.setChecked(visible) + def render_query(self, query: SnapshotQuery) -> None: - if query == self._rendered_query and self.has_unsaved_edits: - return self._rendered_query = query self._topic_filter.setText(query.topic_filter) self._maximum_age.setText( @@ -274,11 +284,23 @@ def set_busy(self, busy: bool) -> None: for widget in self._action_widgets: widget.setEnabled(not busy) + def _set_advanced_visible(self, visible: bool) -> None: + self._advanced_content.setVisible(visible) + self._advanced_button.setText( + "Hide advanced" if visible else "Advanced" + ) + self._advanced_button.setAccessibleName( + "Hide advanced snapshot details" + if visible + else "Show advanced snapshot details" + ) + self.advanced_changed.emit(visible) + def _update_summary_accessibility(self) -> None: summary = ", ".join( label.text() for label in self._summary_labels.values() ) - self.setAccessibleDescription(f"{summary}.") + self._advanced_button.setAccessibleDescription(f"{summary}.") def _render_scope_summary(self) -> None: query = self._rendered_query @@ -306,7 +328,6 @@ def _emit_observe(self) -> None: def _clear_filters(self) -> None: query = SnapshotQuery() - self._rendered_query = None self.render_query(query) self.reset_requested.emit() diff --git a/src/topicgate/gui/components/stored_observations_dialog.py b/src/topicgate/gui/components/stored_observations_dialog.py index b3cac5f..f943575 100644 --- a/src/topicgate/gui/components/stored_observations_dialog.py +++ b/src/topicgate/gui/components/stored_observations_dialog.py @@ -1,8 +1,6 @@ from datetime import datetime from uuid import UUID -from topicgate.gui.icons import IconName, icon - from PySide6.QtCore import QDateTime, Qt, Signal from PySide6.QtWidgets import ( QCheckBox, @@ -16,7 +14,6 @@ QLabel, QLineEdit, QProgressBar, - QScrollArea, QPlainTextEdit, QPushButton, QSpinBox, @@ -33,7 +30,7 @@ from topicgate.core.models.message_filter import OrderType from topicgate.gui.main_view_model import MainViewModel from topicgate.gui.components.history_settings_widget import HistorySettingsWidget -from topicgate.gui.components.quantity_editor import QuantityEditor +from topicgate.gui.components.event_history_widget import EventHistoryWidget from topicgate.presentation.retention_presentation import ( AgeUnit, ByteUnit, @@ -46,6 +43,25 @@ from topicgate.presentation.snapshot_presentation import datetime_label, size_label +class _QuantityEditor(QWidget): + changed = Signal() + + def __init__(self, units: tuple[str, ...], object_name: str) -> None: + super().__init__() + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.value = QLineEdit() + self.value.setObjectName(f"{object_name}Value") + self.value.setAccessibleName(f"{object_name} value") + self.unit = QComboBox() + self.unit.setObjectName(f"{object_name}Unit") + self.unit.setAccessibleName(f"{object_name} unit") + self.unit.addItems(units) + layout.addWidget(self.value, 1) + layout.addWidget(self.unit) + self.value.textChanged.connect(lambda _text: self.changed.emit()) + self.unit.currentIndexChanged.connect(lambda _index: self.changed.emit()) + class StoredObservationsDialog(QDialog): """Application-wide retention policy and persisted cache administration.""" @@ -69,36 +85,22 @@ def __init__(self, view_model: MainViewModel, parent=None) -> None: self.tabs = QTabWidget() self.tabs.setObjectName("storedObservationsPages") self.tabs.tabBar().setObjectName("storedObservationsPages") - self.tabs.tabBar().setExpanding(False) + self.tabs.tabBar().setExpanding(True) self.tabs.setAccessibleName("Stored observations settings pages") - self.tabs.addTab(self._history_page(), "Latest stored values") + self.tabs.addTab(self._history_page(), "Latest stored state") + self.event_history = EventHistoryWidget(view_model) + self.tabs.addTab(self.event_history, "Event history") self.tabs.addTab(self._retention_page(), "Latest-state retention") self.tabs.addTab(self._cache_page(), "Cache administration") self.history_settings = HistorySettingsWidget(view_model) self.tabs.addTab(self.history_settings, "History settings") - for index in range(self.tabs.count()): - page = self.tabs.widget(index) - title = self.tabs.tabText(index) - self.tabs.removeTab(index) - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setFrameShape(QScrollArea.Shape.NoFrame) - scroll.setWidget(page) - self.tabs.insertTab(index, scroll, title) - self.tabs.setCurrentIndex(0) - self.tabs.currentChanged.connect(self._page_changed) layout.addWidget(self.tabs) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) - buttons.button(QDialogButtonBox.StandardButton.Close).setIcon(icon(IconName.CLOSE)) buttons.rejected.connect(self.reject) layout.addWidget(buttons) self._view_model.stored_observations_changed.connect(self.render) self._view_model.operation_state_changed.connect(self._render_busy) - def _page_changed(self) -> None: - if self.tabs.currentIndex() == 3: - self.history_settings.request_load() - def _history_page(self) -> QWidget: page = QWidget() page.setObjectName("observationHistoryPage") @@ -243,7 +245,7 @@ def _retention_page(self) -> QWidget: self.unlimited_age = QCheckBox("Unlimited") self.unlimited_age.setObjectName("unlimitedRetentionAge") self.unlimited_age.setAccessibleName("Keep observations indefinitely") - self.maximum_age = QuantityEditor( + self.maximum_age = _QuantityEditor( tuple(item.value for item in AgeUnit), "maximumRetentionAge", ) @@ -291,7 +293,7 @@ def _retention_page(self) -> QWidget: layout.addLayout(form) layout.addStretch(1) for widget in self._draft_widgets(): - if isinstance(widget, QuantityEditor): + if isinstance(widget, _QuantityEditor): widget.changed.connect(self._draft_changed) elif isinstance(widget, QLineEdit): widget.textChanged.connect(self._draft_changed) @@ -360,17 +362,13 @@ def _cache_page(self) -> QWidget: layout.addWidget(self.topics) actions = QHBoxLayout() for text, name, scope in ( - ("Delete selected cached topics…", "deleteSelectedTopicsButton", "selected_topics"), + ("Delete selected topics…", "deleteSelectedTopicsButton", "selected_topics"), ("Delete unsubscribed…", "deleteUnsubscribedButton", "unsubscribed"), ("Delete broker cache…", "deleteBrokerCacheButton", "broker"), - ("Delete cache for all brokers…", "deleteAllCachesButton", "all_brokers"), + ("Delete all brokers…", "deleteAllCachesButton", "all_brokers"), ): button = QPushButton(text) button.setObjectName(name) - button.setProperty("danger", True) - button.setIcon(icon(IconName.DELETE)) - if scope == "all_brokers": - button.setToolTip("Delete latest stored values for all brokers. Broker profiles, event history, and failure history are not deleted.") button.setAccessibleName(text.rstrip("…")) button.clicked.connect( lambda _checked=False, selected_scope=scope: ( @@ -669,7 +667,7 @@ def integer(name: str, widget: QLineEdit) -> int: errors[name] = str(error) return 0 - def byte_limit(name: str, editor: QuantityEditor) -> int: + def byte_limit(name: str, editor: _QuantityEditor) -> int: try: return self._byte_value(editor) except ValueError as error: @@ -815,8 +813,8 @@ def _integer_field(self, form, title: str, name: str) -> QLineEdit: self._add_error_row(form, title, widget, self._field_name(name)) return widget - def _byte_field(self, form, title: str, name: str) -> QuantityEditor: - widget = QuantityEditor(tuple(item.value for item in ByteUnit), name) + def _byte_field(self, form, title: str, name: str) -> _QuantityEditor: + widget = _QuantityEditor(tuple(item.value for item in ByteUnit), name) widget.setAccessibleName(title) widget.value.setAccessibleName(f"{title} value") widget.unit.setAccessibleName(f"{title} unit") @@ -873,14 +871,14 @@ def _positive_int(widget: QLineEdit) -> int: raise ValueError("Enter a positive integer.") return value - def _byte_value(self, editor: QuantityEditor) -> int: + def _byte_value(self, editor: _QuantityEditor) -> int: return exact_byte_value( self._positive_int(editor.value), ByteUnit(editor.unit.currentText()), ) @staticmethod - def _set_byte_value(editor: QuantityEditor, stored: int) -> None: + def _set_byte_value(editor: _QuantityEditor, stored: int) -> None: value, unit = display_byte_value(stored) editor.value.setText(str(value)) editor.unit.setCurrentText(unit.value) diff --git a/src/topicgate/gui/components/subscription_settings.py b/src/topicgate/gui/components/subscription_settings.py index 4c003ca..a7ac1f7 100644 --- a/src/topicgate/gui/components/subscription_settings.py +++ b/src/topicgate/gui/components/subscription_settings.py @@ -41,7 +41,7 @@ def __init__(self) -> None: ) self.content_layout.addWidget(self._hint) - form = self._form = QFormLayout() + form = QFormLayout() self._filter_edit = QLineEdit() self._qos_combo = self._compact_combo( [ @@ -58,17 +58,13 @@ def __init__(self) -> None: "2 - Do not send retained messages", ] ) - form.addRow("MQTT subscription", self._filter_edit) + form.addRow("Filter", self._filter_edit) form.addRow("QoS", self._qos_combo) form.addRow("Retain", self._retain_as_published) form.addRow("Handling", self._retain_handling) - self._options_summary = QLabel() - self._options_summary.setWordWrap(True) - self._options_summary.setObjectName("subscriptionOptionsSummary") - self._advanced_mode = True - form.addRow(self._options_summary) self.content_layout.addLayout(form) + self.content_layout.addStretch(1) buttons = QHBoxLayout() buttons.addStretch(1) self._apply_button = QPushButton("Apply") @@ -77,22 +73,13 @@ def __init__(self) -> None: self._apply_button.clicked.connect(self._apply) buttons.addWidget(self._apply_button) self.content_layout.addLayout(buttons) - self._feedback = QLabel() - self._feedback.setObjectName("subscriptionApplyFeedback") - self._feedback.setTextFormat(Qt.TextFormat.PlainText) - self._feedback.setWordWrap(True) - self.content_layout.addWidget(self._feedback) - self.content_layout.addStretch(1) def render( self, selected_topic: str, subscription: Subscription | None, ) -> None: - if subscription == self._subscription and self.has_unsaved_edits: - return self._selected_topic = selected_topic - self._feedback.clear() self._subscription = subscription self._set_editor_enabled(subscription is not None) @@ -102,7 +89,6 @@ def render( self._qos_combo.setCurrentIndex(0) self._retain_as_published.setChecked(False) self._retain_handling.setCurrentIndex(0) - self.set_advanced_mode(self._advanced_mode) return if subscription.topic_filter == selected_topic: @@ -115,30 +101,6 @@ def render( self._qos_combo.setCurrentIndex(subscription.qos) self._retain_as_published.setChecked(subscription.retain_as_published) self._retain_handling.setCurrentIndex(subscription.retain_handling) - self.set_advanced_mode(self._advanced_mode) - - @property - def has_unsaved_edits(self) -> bool: - subscription = self._subscription - return subscription is not None and ( - self._filter_edit.text() != subscription.topic_filter - or self._qos_combo.currentIndex() != subscription.qos - or self._retain_as_published.isChecked() != subscription.retain_as_published - or self._retain_handling.currentIndex() != subscription.retain_handling - ) - - def set_advanced_mode(self, advanced: bool) -> None: - self._advanced_mode = advanced - for widget in (self._retain_as_published, self._retain_handling): - self._form.setRowVisible(widget, advanced) - self._options_summary.setText( - "Retained messages: " + self._retain_handling.currentText().split(" - ", 1)[-1] - + ("; preserve retained flag." if self._retain_as_published.isChecked() else ".") - ) - self._options_summary.setVisible( - not advanced and self._subscription is not None - and (self._retain_as_published.isChecked() or self._retain_handling.currentIndex() != 0) - ) def _apply(self) -> None: if self._subscription is None: @@ -155,9 +117,6 @@ def _apply(self) -> None: return self.apply_requested.emit(self._subscription.topic_filter, updated) - def show_feedback(self, message: str) -> None: - self._feedback.setText(message) - def _set_editor_enabled(self, enabled: bool) -> None: for widget in ( self._filter_edit, diff --git a/src/topicgate/gui/components/topic_details.py b/src/topicgate/gui/components/topic_details.py index fbf7d46..ce61691 100644 --- a/src/topicgate/gui/components/topic_details.py +++ b/src/topicgate/gui/components/topic_details.py @@ -21,7 +21,7 @@ WorkspacePane, ) from topicgate.gui.components.topic_metadata import TopicMetadataPane -from topicgate.gui.icons import IconName, icon +from topicgate.gui.icons import edit_icon class TopicDetailsPane(WorkspacePane): @@ -52,7 +52,7 @@ def __init__(self) -> None: self._edit_button = QToolButton() self._edit_button.setObjectName("topicEditButton") self._edit_button.setFixedHeight(WORKSPACE_CONTROL_HEIGHT) - self._edit_button.setIcon(icon(IconName.SETTINGS)) + self._edit_button.setIcon(edit_icon()) self._edit_button.setIconSize(QSize(14, 14)) self._edit_button.setToolButtonStyle( Qt.ToolButtonStyle.ToolButtonTextBesideIcon @@ -77,6 +77,7 @@ def __init__(self) -> None: mode_layout.addWidget(self._mode_tabs, 1) mode_layout.addWidget(self._edit_button) self.content_layout.addLayout(mode_layout) + self._payload_content = QWidget() self._payload_content.setObjectName("topicPayloadContent") payload_layout = QVBoxLayout(self._payload_content) @@ -137,6 +138,7 @@ def __init__(self) -> None: payload_layout.addWidget(self._filter_summary, 1) self._metadata = TopicMetadataPane() + self._metadata.advanced_changed.connect(self._set_advanced_visible) payload_layout.addWidget(self._metadata) self._snapshot_scope_note = QLabel() @@ -264,8 +266,7 @@ def set_settings_visible(self, visible: bool) -> None: def focus_payload(self) -> None: self._decoded_payload.setFocus(Qt.FocusReason.OtherFocusReason) - def set_advanced_mode(self, visible: bool) -> None: - self._metadata.set_advanced_mode(visible) + def _set_advanced_visible(self, visible: bool) -> None: self._advanced_visible = visible self._update_raw_visibility() @@ -285,8 +286,6 @@ def _set_mode(self, index: int) -> None: self._update_raw_visibility() def _toggle_subscription_editing(self, editing: bool) -> None: - self._edit_button.setText("Close settings" if editing else "Settings") - self._edit_button.setIcon(icon(IconName.CLOSE if editing else IconName.SETTINGS)) self._edit_button.setToolTip( "Hide topic settings" if editing diff --git a/src/topicgate/gui/components/topic_metadata.py b/src/topicgate/gui/components/topic_metadata.py index 971aa93..8ca7c61 100644 --- a/src/topicgate/gui/components/topic_metadata.py +++ b/src/topicgate/gui/components/topic_metadata.py @@ -1,5 +1,5 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QFormLayout, QLabel, QWidget +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QFormLayout, QLabel, QToolButton, QWidget from topicgate.presentation.topic_presentation import TopicDetail @@ -7,6 +7,8 @@ class TopicMetadataPane(QWidget): """Compact metadata renderer for the selected topic detail.""" + advanced_changed = Signal(bool) + def __init__(self) -> None: super().__init__() self._form = QFormLayout(self) @@ -60,7 +62,14 @@ def __init__(self) -> None: self.qos, self.dropped, ) - self.set_advanced_mode(False) + self._advanced_button = QToolButton() + self._advanced_button.setObjectName("topicMetadataAdvancedButton") + self._advanced_button.setCheckable(True) + self._advanced_button.setText("Advanced") + self._advanced_button.setAccessibleName("Show advanced topic details") + self._advanced_button.toggled.connect(self._set_advanced_visible) + self._form.addRow(self._advanced_button) + self._set_advanced_visible(False) def render(self, detail: TopicDetail) -> None: self.received.setText(detail.received_at) @@ -83,9 +92,18 @@ def render(self, detail: TopicDetail) -> None: self.messages.setText(str(detail.message_count)) self.dropped.setText(str(detail.dropped_message_count)) - def set_advanced_mode(self, advanced: bool) -> None: + def _set_advanced_visible(self, visible: bool) -> None: for widget in self._advanced_widgets: - self._form.setRowVisible(widget, advanced) + self._form.setRowVisible(widget, visible) + self._advanced_button.setText( + "Hide advanced" if visible else "Advanced" + ) + self._advanced_button.setAccessibleName( + "Hide advanced topic details" + if visible + else "Show advanced topic details" + ) + self.advanced_changed.emit(visible) @staticmethod def _label(name: str, text: str = "-") -> QLabel: diff --git a/src/topicgate/gui/icons.py b/src/topicgate/gui/icons.py index ebb8c1f..0fdac2b 100644 --- a/src/topicgate/gui/icons.py +++ b/src/topicgate/gui/icons.py @@ -1,29 +1,11 @@ -"""Shared semantic catalog for desktop control icons.""" - -from enum import StrEnum -from pathlib import Path - from PySide6.QtGui import QIcon from topicgate.paths import asset_path -class IconName(StrEnum): - BROKER = "broker" - CLOSE = "close" - CREATE = "create" - DELETE = "delete" - EDIT = "edit" - HELP = "help" - OBSERVER_TREE = "observer-tree" - SETTINGS = "settings" +def edit_icon() -> QIcon: + return QIcon(asset_path("edit.svg")) -def icon(name: IconName) -> QIcon: - """Load a packaged control icon, rejecting undeclared names and missing assets.""" - if not isinstance(name, IconName): - raise TypeError("Control icons require an IconName") - path = Path(asset_path(f"icons/{name.value}.svg")) - if not path.is_file(): - raise FileNotFoundError(path) - return QIcon(str(path)) +def delete_icon() -> QIcon: + return QIcon(asset_path("delete.svg")) diff --git a/src/topicgate/gui/main_view_model.py b/src/topicgate/gui/main_view_model.py index 4ec2afd..805597c 100644 --- a/src/topicgate/gui/main_view_model.py +++ b/src/topicgate/gui/main_view_model.py @@ -20,10 +20,7 @@ FailureHistoryResult, FindingCheckpoint, ) -from topicgate.app.services.broker_snapshot_service import ( - MAX_SNAPSHOT_RESULT_LIMIT, - BrokerSnapshotService, -) +from topicgate.app.services.broker_snapshot_service import BrokerSnapshotService from topicgate.app.services.expectation_management_service import ( ExpectationManagementService, ) @@ -139,7 +136,6 @@ def __init__( 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.event_history_result: TopicHistoryResult | None = None self.event_history_error: str | None = None @@ -157,7 +153,6 @@ def __init__( self._snapshot_query = SnapshotQuery() self._topic = topic self._snapshot = self._build_current_snapshot(self._snapshot_query) - self._observer_snapshot = self._build_observer_snapshot() self._message_task: asyncio.Task[None] | None = None self._connection_task: asyncio.Task[None] | None = None self._connection_status = self._status_text( @@ -228,11 +223,6 @@ def topic_expectations(self) -> tuple[HealthExpectation, ...]: and item.target.topic == self._topic ) - @property - def all_expectations(self) -> tuple[HealthExpectation, ...]: - """Configured rules for the selected broker, including topic rules.""" - return self._broker_health_expectations() - @property def broker_expectations(self) -> tuple[HealthExpectation, ...]: if self._expectation_management_service is None: @@ -790,17 +780,17 @@ def subscriptions(self) -> tuple[Subscription, ...]: @property def topic_paths(self) -> list[str]: subscriptions = self.subscriptions - observed_topics = tuple(item.topic for item in self._observer_snapshot.topics) + observed_topics = tuple(item.topic for item in self._snapshot.topics) return list(collect_visible_topic_paths(subscriptions, observed_topics)) @property def topic_tree(self) -> tuple[TopicTreeNode, ...]: - observed_topics = tuple(item.topic for item in self._observer_snapshot.topics) + observed_topics = tuple(item.topic for item in self._snapshot.topics) return build_topic_tree( self.topic_paths, self.subscriptions, observed_topics, - self._observer_snapshot.topics, + self._snapshot.topics, ) @property @@ -824,7 +814,7 @@ def selected_wildcard_filter_summary(self) -> WildcardFilterSummary | None: subscription = self.selected_wildcard_subscription if subscription is None: return None - return wildcard_filter_summary(subscription, self._observer_snapshot.topics) + return wildcard_filter_summary(subscription, self._snapshot.topics) async def start(self) -> None: """Load the current value and listen for messages and connection changes.""" @@ -885,7 +875,6 @@ def refresh_snapshot( ) -> None: """Capture current state without reconnecting or mutating observations.""" self._snapshot = self._build_current_snapshot(self._snapshot_query) - self._observer_snapshot = self._build_observer_snapshot() snapshot_topics = {item.topic for item in self._snapshot.topics} if ( clear_invalid_selection @@ -950,7 +939,6 @@ async def query_event_history( self.event_history_changed.emit() async def load_history_settings(self, broker_id: UUID) -> None: - self.history_settings_feedback = "" self._history_settings_generation += 1 generation = self._history_settings_generation try: @@ -973,22 +961,6 @@ async def load_history_settings(self, broker_id: UUID) -> None: self.history_settings_error = None self.history_settings_changed.emit() - 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"): - try: - await asyncio.to_thread(self._runtime.set_history_recording, broker_id, enabled) - except Exception: - self.history_settings_broker = broker_id - self.history_settings_error = "Recording could not be changed. Reload status to retry." - self.history_settings_changed.emit() - return - await self.load_history_settings(broker_id) - - if not self.history_settings_error: - self.history_settings_feedback = f"Recording {'enabled' if enabled else 'disabled'}." - self.history_settings_changed.emit() - async def save_history_settings( self, broker_id: UUID, enabled: bool, policy: HistoryRetentionPolicy, ) -> None: @@ -1004,9 +976,6 @@ async def save_history_settings( self.history_settings_changed.emit() return await self.load_history_settings(broker_id) - if not self.history_settings_error: - self.history_settings_feedback = "History settings applied." - self.history_settings_changed.emit() async def load_stored_observations( self, @@ -1353,7 +1322,6 @@ async def reconnect_and_observe( self._preserve_snapshot_during_observation = False self._snapshot_query = selected_query self._snapshot = snapshot - self._observer_snapshot = self._build_observer_snapshot() if self._topic and self._topic not in self.topic_paths: self._topic = "" self._connection_status = snapshot.connection_status @@ -1613,11 +1581,3 @@ def _build_current_snapshot(self, query: SnapshotQuery) -> BrokerSnapshot: result_limit=query.result_limit, payload_limit_bytes=query.payload_limit_bytes, ) - - def _build_observer_snapshot(self) -> BrokerSnapshot: - return self._snapshot_service.build_current( - self.active_broker_profile.id, - topic_filter="#", - result_limit=MAX_SNAPSHOT_RESULT_LIMIT, - payload_limit_bytes=0, - ) diff --git a/src/topicgate/gui/main_window.py b/src/topicgate/gui/main_window.py index 79dcf0c..d19c435 100644 --- a/src/topicgate/gui/main_window.py +++ b/src/topicgate/gui/main_window.py @@ -5,22 +5,17 @@ from typing import Any from uuid import UUID -from topicgate.gui.icons import IconName, icon - from PySide6.QtCore import QByteArray, QSettings, Qt, QTimer from PySide6.QtGui import QAction, QCloseEvent, QIcon, QResizeEvent, QShowEvent from PySide6.QtWidgets import ( QFileDialog, - QLabel, QMainWindow, - QPushButton, QMenu, QMessageBox, QScrollArea, QSplitter, QStackedWidget, QTabWidget, - QTabBar, QVBoxLayout, QWidget, ) @@ -34,7 +29,6 @@ ) from topicgate.gui.components.broker_connection import BrokerConnectionPane from topicgate.gui.components.connection_controls import ConnectionControls -from topicgate.gui.components.event_history_widget import EventHistoryWidget from topicgate.gui.components.log_console import LogConsoleDock from topicgate.gui.components.expectation_editor import ExpectationEditor from topicgate.gui.components.health_inspector import HealthInspector @@ -90,7 +84,6 @@ def __init__( self._diagnostic_profile_editor_window = None self._operation_tasks: set[asyncio.Task[None]] = set() self._accepting_operations = True - self._initial_focus_set = False self._settings = settings or QSettings() self._stored_observations_dialog: StoredObservationsDialog | None = None self._mcp_setup_dialog: McpSetupDialog | None = None @@ -99,7 +92,6 @@ def __init__( ) = None if settings is None: migrate_legacy_settings(self._settings) - self._advanced_mode = self._settings.value("workspace/advancedMode", False, type=bool) self.setWindowTitle(view_model.title) self.setObjectName("mainWindow") self.setStyleSheet(LIGHT_THEME) @@ -111,7 +103,6 @@ def __init__( self._connect_view_model() self._restore_state() self._render_all() - self._apply_advanced_mode() def _create_workspace(self) -> None: self._observer_tree = ObserverTreePane() @@ -159,6 +150,9 @@ def _create_workspace(self) -> None: message, ) ) + self._observer_tree.empty_state_action_requested.connect( + self._handle_empty_state_action + ) self._onboarding.configure_broker_requested.connect( self._show_broker_settings_dialog ) @@ -194,11 +188,7 @@ def _create_workspace(self) -> None: self._settings_tabs.tabBar().setFixedHeight(WORKSPACE_CONTROL_HEIGHT) self._settings_tabs.addTab(self._subscription_settings, "Subscription") self._settings_tabs.addTab(self._topic_expectations, "Expectations") - settings_scroll = QScrollArea() - settings_scroll.setWidgetResizable(True) - settings_scroll.setFrameShape(QScrollArea.Shape.NoFrame) - settings_scroll.setWidget(self._settings_tabs) - self._context_panel.content_layout.addWidget(settings_scroll) + self._context_panel.content_layout.addWidget(self._settings_tabs) self._observer_workspace = QWidget() self._observer_workspace.setObjectName("observerWorkspace") @@ -212,14 +202,6 @@ def _create_workspace(self) -> None: self._topic_inspector.setObjectName("topicInspector") inspector_layout = QVBoxLayout(self._topic_inspector) inspector_layout.setContentsMargins(0, 0, 0, 0) - self._destination_tabs = QTabBar() - self._destination_tabs.setObjectName("workspaceDestinations") - self._destination_tabs.setAccessibleName("Workspace destination") - self._destination_tabs.setExpanding(False) - self._destination_tabs.setDrawBase(True) - for title in ("Health", "Selected", "Snapshot", "History"): - self._destination_tabs.addTab(title) - self._destination_tabs.currentChanged.connect(self._navigate) self._inspector_stack = QStackedWidget() self._inspector_stack.setObjectName("inspectorStack") snapshot_scroll = QScrollArea() @@ -232,15 +214,7 @@ def _create_workspace(self) -> None: snapshot_scroll.setWidget(self._snapshot_panel) self._inspector_stack.addWidget(snapshot_scroll) self._inspector_stack.addWidget(self._topic_details) - self._event_history = EventHistoryWidget(self._view_model) - self._connect_event_history(self._event_history) - history_scroll = QScrollArea() - history_scroll.setWidgetResizable(True) - history_scroll.setFrameShape(QScrollArea.Shape.NoFrame) - history_scroll.setWidget(self._event_history) - self._inspector_stack.addWidget(history_scroll) self._inspector_stack.addWidget(self._health_inspector) - self._inspector_stack.currentChanged.connect(self._render_destination) inspector_layout.addWidget(self._inspector_stack) self._splitter = QSplitter(Qt.Orientation.Horizontal) @@ -257,7 +231,6 @@ def _create_workspace(self) -> None: root_layout = QVBoxLayout(root) root_layout.setContentsMargins(12, 12, 12, 12) root_layout.setSpacing(10) - root_layout.addWidget(self._destination_tabs) root_layout.addWidget(self._onboarding) root_layout.addWidget(self._splitter, 1) self.setCentralWidget(root) @@ -265,42 +238,6 @@ def _create_workspace(self) -> None: self.resize(1280, 800) self._splitter.setSizes([330, 580, 330]) self._context_panel.setHidden(True) - self._render_destination() - - def _render_destination(self) -> None: - self._destination_tabs.blockSignals(True) - self._destination_tabs.setCurrentIndex( - (2, 1, 3, 0)[self._inspector_stack.currentIndex()] - ) - self._destination_tabs.setTabEnabled(1, bool(self._view_model.topic)) - self._destination_tabs.blockSignals(False) - - def _navigate(self, index: int) -> None: - (self._show_health, self._show_topic_details, - self._show_snapshot, self._show_history)[index]() - - def _show_history(self) -> None: - self._inspector_stack.setCurrentIndex(2) - self._context_panel.setHidden(True) - self._event_history.select_workspace_broker() - self._event_history.request_recording_status() - - def _connect_event_history(self, widget: EventHistoryWidget) -> None: - widget.recording_status_requested.connect( - lambda broker_id: self._run_async(self._view_model.load_history_settings(broker_id)) - ) - widget.recording_requested.connect( - lambda broker_id, enabled: self._run_async( - self._view_model.set_history_recording(broker_id, enabled) - ) - ) - widget.query_requested.connect( - lambda broker_id, topic_filter, after, before, cursor, limit: self._run_async( - self._view_model.query_event_history( - broker_id, topic_filter, after, before, cursor, limit - ) - ) - ) def _set_context_panel_visible(self, visible: bool) -> None: self._context_panel.setVisible( @@ -308,10 +245,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) @@ -327,7 +260,7 @@ def _show_health(self) -> None: self._schedule_health_refresh() def _show_diagnostic_profiles(self) -> None: - if not self._advanced_mode or self._diagnostic_profile_editor is None: + if self._diagnostic_profile_editor is None: return broker_id = self._view_model.active_broker_profile.id if self._diagnostic_profile_editor_window is None: @@ -365,23 +298,13 @@ def _select_topic(self, topic: str) -> None: self._view_model.select_topic(topic) def _create_actions(self) -> None: - self._advanced_indicator = QLabel("Advanced mode") - self._advanced_indicator.setObjectName("advancedModeIndicator") - self.statusBar().addPermanentWidget(self._advanced_indicator) - self._advanced_mode_action = QAction("Advanced mode", self) - self._advanced_mode_action.setObjectName("advancedModeAction") - self._advanced_mode_action.setCheckable(True) - self._advanced_mode_action.setChecked(self._advanced_mode) - self._advanced_mode_action.toggled.connect(self._request_advanced_mode) self._broker_settings_action = QAction("&Edit broker profile...", self) - self._broker_settings_action.setIcon(icon(IconName.EDIT)) self._broker_settings_action.setObjectName("brokerSettingsAction") self._broker_settings_action.setToolTip("Edit the active broker profile") self._broker_settings_action.triggered.connect( self._show_broker_settings_dialog ) self._add_broker_profile_action = QAction("&Add broker profile...", self) - self._add_broker_profile_action.setIcon(icon(IconName.CREATE)) self._add_broker_profile_action.setObjectName("addBrokerProfileAction") self._add_broker_profile_action.triggered.connect( self._show_create_broker_profile_dialog @@ -390,7 +313,6 @@ def _create_actions(self) -> None: "&Delete broker profile...", self, ) - self._delete_broker_profile_action.setIcon(icon(IconName.DELETE)) self._delete_broker_profile_action.setObjectName( "deleteBrokerProfileAction" ) @@ -439,10 +361,12 @@ def _create_actions(self) -> None: self._broker_connection.disconnect_requested.connect( lambda: self._run_async(self._view_model.disconnect_from_broker()) ) + self._broker_connection.inspect_snapshot_requested.connect( + self._show_snapshot + ) self._broker_connection.health_requested.connect(self._show_health) - self._add_filter_action = QAction("Add subscription", self) - self._add_filter_action.setIcon(icon(IconName.CREATE)) + self._add_filter_action = QAction("Add filter", self) self._add_filter_action.setShortcut("Ctrl+N") self._add_filter_action.setToolTip("Add an MQTT subscription filter") self._add_filter_action.triggered.connect(self._show_add_filter_dialog) @@ -472,12 +396,7 @@ def _create_actions(self) -> None: self._health_action.setShortcut("Ctrl+Shift+H") self._health_action.triggered.connect(self._show_health) - self._history_action = QAction("History", self) - self._history_action.setObjectName("historyAction") - self._history_action.triggered.connect(self._show_history) - self._diagnostic_profiles_action = QAction("Diagnostic profiles...", self) - self._diagnostic_profiles_action.setIcon(icon(IconName.SETTINGS)) self._diagnostic_profiles_action.setObjectName("diagnosticProfilesAction") self._diagnostic_profiles_action.setEnabled( self._diagnostic_profile_editor is not None @@ -487,17 +406,14 @@ def _create_actions(self) -> None: ) self._quit_action = QAction("Quit", self) - self._quit_action.setIcon(icon(IconName.CLOSE)) self._quit_action.setShortcut("Ctrl+Q") self._quit_action.triggered.connect(self.close) self._about_action = QAction("About TopicGate", self) - self._about_action.setIcon(icon(IconName.HELP)) self._about_action.setObjectName("aboutAction") self._about_action.triggered.connect(self._show_about_dialog) self._mcp_setup_action = QAction("MCP setup...", self) - self._mcp_setup_action.setIcon(icon(IconName.SETTINGS)) self._mcp_setup_action.setObjectName("mcpSetupAction") self._mcp_setup_action.setToolTip("Show TopicGate MCP client configuration") self._mcp_setup_action.setShortcut("Ctrl+Shift+M") @@ -530,10 +446,7 @@ def _create_menu_bar(self) -> None: self.menuBar().addAction(self._stored_observations_action) self._view_menu: QMenu = self.menuBar().addMenu("&View") - self._view_menu.addAction(self._advanced_mode_action) - self._view_menu.addSeparator() self._view_menu.addAction(self._health_action) - self._view_menu.addAction(self._history_action) self._view_menu.addAction(self._diagnostic_profiles_action) self._view_menu.addSeparator() self._view_menu.addAction(self._expand_action) @@ -552,83 +465,11 @@ def _create_log_dock(self) -> None: self._log_dock, ) self._console_action.setChecked(self._log_dock.isVisible()) - self._console_action.toggled.connect(self._set_console_visible) + self._console_action.toggled.connect(self._log_dock.setVisible) self._log_dock.visibilityChanged.connect(self._console_action.setChecked) self._view_menu.addSeparator() self._view_menu.addAction(self._console_action) - def _set_console_visible(self, visible: bool) -> None: - if self._advanced_mode: - self._log_visible_preference = visible - self._log_dock.setVisible(visible and self._advanced_mode) - - def _request_advanced_mode(self, advanced: bool) -> None: - if advanced == self._advanced_mode: - return - if not advanced: - open_specialist = any( - window is not None and window.isVisible() - for window in ( - self._stored_observations_dialog, - self._diagnostic_profile_editor_window, - ) - ) - dirty = [ - name for name, pane in ( - ("Snapshot", self._snapshot_panel), - ("Subscription", self._subscription_settings), - ("Topic expectations", self._topic_expectations), - ("Broker expectations", self._health_inspector._broker_expectations), - ) if pane.has_unsaved_edits - ] - if open_specialist or dirty: - self._advanced_mode_action.blockSignals(True) - self._advanced_mode_action.setChecked(self._advanced_mode) - self._advanced_mode_action.blockSignals(False) - detail = ( - "Close Stored observations and Diagnostic profiles before switching. " - "Resolve any edits there using their existing controls." - if open_specialist else - "Unfinished edits in: " + ", ".join(dirty) + ". Finish applying or saving " - "these edits before switching." - ) - QMessageBox.information( - self, "Advanced mode remains active", detail + " No edits were changed." - ) - return - self._advanced_mode = advanced - self._apply_advanced_mode() - self._settings.setValue("workspace/advancedMode", advanced) - self._settings.sync() - - 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.blockSignals(False) - if not advanced and self._inspector_stack.currentIndex() == 0: - self._show_snapshot() - for action in (self._stored_observations_action, self._diagnostic_profiles_action, - self._console_action): - action.setVisible(advanced) - action.setEnabled(advanced) - self._diagnostic_profiles_action.setEnabled( - advanced and self._diagnostic_profile_editor is not None - ) - self._log_dock.toggleViewAction().setVisible(advanced) - self._log_dock.toggleViewAction().setEnabled(advanced) - self._log_dock.setVisible(advanced and self._log_visible_preference) - self._topic_details.set_advanced_mode(advanced) - self._subscription_settings.set_advanced_mode(advanced) - self._topic_expectations.set_advanced_mode(advanced) - self._health_inspector.set_advanced_mode(advanced) - self._event_history.set_advanced_mode(advanced) - self._observer_tree.set_advanced_mode(advanced) - self._advanced_indicator.setVisible(advanced) - self.statusBar().setVisible(advanced) - self._render_destination() - def _show_about_dialog(self) -> None: AboutDialog(self).open() @@ -712,6 +553,16 @@ def _dismiss_onboarding(self) -> None: self._settings.setValue("onboarding/dismissed", True) self._onboarding.setVisible(False) + def _handle_empty_state_action(self, action: str) -> None: + if action == "add-filter": + self._show_add_filter_dialog() + elif action == "connect": + self._run_async(self._view_model.connect_to_broker()) + elif action == "clear-filters": + self._reset_snapshot_query() + elif action == "observe": + self._confirm_reconnect_and_observe() + def _connect_view_model(self) -> None: self._view_model.state_changed.connect(self._render_details) self._view_model.topics_changed.connect(self._render_tree) @@ -760,13 +611,21 @@ def _render_tree(self) -> None: self._view_model.snapshot_health ) snapshot = self._view_model.broker_snapshot - self._observer_tree.render_scope( - self._view_model.snapshot_query, snapshot, len(self._view_model.subscriptions) + self._observer_tree.render_empty_state( + self._view_model.connection_status, + self._view_model.subscriptions, + self._snapshot_query_is_filtered(), + bool(snapshot.topics) + and all(item.source.value == "stored" for item in snapshot.topics), + bool(snapshot.topics), + ) + self._observer_tree.set_connection_busy( + self._view_model.is_busy("broker") + or self._view_model.is_busy("connection") ) self._render_broker_connection() def _render_details(self) -> None: - self._render_destination() self._topic_details.render(self._view_model) self._topic_expectations.render() if ( @@ -794,7 +653,6 @@ def _render_connection(self) -> None: self._render_onboarding() def _render_broker_profiles(self) -> None: - self._event_history.select_workspace_broker() self._render_connection_controls() self._render_onboarding() @@ -847,6 +705,7 @@ def _render_operation_state(self) -> None: self._snapshot_panel.set_busy( exclusive_busy ) + self._observer_tree.set_connection_busy(exclusive_busy) self._stored_observations_action.setEnabled( not exclusive_busy ) @@ -881,9 +740,16 @@ def _render_onboarding(self) -> None: ), ) + def _snapshot_query_is_filtered(self) -> bool: + query = self._view_model.snapshot_query + return ( + query.topic_filter != "#" + or query.max_age_seconds is not None + or query.result_limit != SnapshotQuery().result_limit + or query.payload_limit_bytes != SnapshotQuery().payload_limit_bytes + ) + def _show_stored_observations(self) -> None: - if not self._advanced_mode: - return dialog = self._stored_observations_dialog if dialog is None: dialog = StoredObservationsDialog(self._view_model, self) @@ -891,6 +757,11 @@ def _show_stored_observations(self) -> None: dialog.history_settings.load_requested.connect( lambda broker_id: self._run_async(self._view_model.load_history_settings(broker_id)) ) + dialog.event_history.query_requested.connect( + lambda broker_id, topic_filter, after, before, cursor, limit: self._run_async( + self._view_model.query_event_history(broker_id, topic_filter, after, before, cursor, limit) + ) + ) dialog.history_settings.save_requested.connect( lambda broker_id, enabled, policy: self._run_async( self._view_model.save_history_settings(broker_id, enabled, policy) @@ -947,6 +818,7 @@ def _show_stored_observations(self) -> None: dialog.raise_() dialog.activateWindow() self._run_async(self._view_model.load_stored_observations()) + self._run_async(self._view_model.load_history_settings(dialog.history_settings.broker.currentData())) async def _preview_and_save_retention_policy(self, policy) -> None: preview = await self._view_model.preview_retention_policy(policy) @@ -1113,7 +985,6 @@ def _show_operation_error(self, title: str, message: str) -> None: "Edit broker profile...", QMessageBox.ButtonRole.ActionRole ) close = dialog.addButton(QMessageBox.StandardButton.Close) - close.setIcon(icon(IconName.CLOSE)) dialog.setDefaultButton(close) dialog.exec() if dialog.clickedButton() is retry: @@ -1126,14 +997,11 @@ def _apply_subscription( original_filter: str, subscription: Subscription, ) -> None: - self._run_async(self._save_subscription(original_filter, subscription)) - - async def _save_subscription( - self, original_filter: str, subscription: Subscription, - ) -> None: - await self._view_model.update_subscription(original_filter, subscription) - self._subscription_settings.show_feedback( - f"Subscription applied: {subscription.topic_filter}" + self._run_async( + self._view_model.update_subscription( + original_filter, + subscription, + ) ) def _show_add_filter_dialog(self) -> None: @@ -1199,6 +1067,7 @@ def _confirm_broker_profile_switch(self, profile_id: UUID) -> None: ) if result == QMessageBox.StandardButton.Yes: self._render_connection_controls(True) + self._observer_tree.set_connection_busy(True) self._run_async( self._switch_broker_profile(profile_id, next_profile.config) ) @@ -1217,6 +1086,7 @@ async def _switch_broker_profile( if self._view_model.active_broker_profile.id != previous_profile_id: self._show_snapshot() self._render_connection_controls() + self._observer_tree.set_connection_busy(False) def _confirm_delete_broker_profile( self, @@ -1248,6 +1118,7 @@ def _confirm_delete_broker_profile( ) if result == QMessageBox.StandardButton.Yes: self._render_connection_controls(True) + self._observer_tree.set_connection_busy(True) self._run_async(self._delete_broker_profile(profile.id)) async def _delete_broker_profile(self, profile_id: UUID) -> None: @@ -1255,6 +1126,7 @@ async def _delete_broker_profile(self, profile_id: UUID) -> None: await self._view_model.delete_broker_profile(profile_id) finally: self._render_connection_controls() + self._observer_tree.set_connection_busy(False) def _apply_broker_settings(self, dialog: BrokerSettingsDialog) -> None: try: @@ -1371,13 +1243,16 @@ def _restore_state(self) -> None: if selected_topic: self._view_model.select_topic(selected_topic) self._restore_snapshot_preferences() - self._show_health() - self._log_visible_preference = self._settings.value( + if self._view_model.topic: + self._show_topic_details() + else: + self._show_snapshot() + log_visible = self._settings.value( "workspace/logVisible", False, type=bool, ) - self._log_dock.setVisible(self._log_visible_preference and self._advanced_mode) + self._log_dock.setVisible(log_visible) def _restore_snapshot_preferences(self) -> None: topic_filter = str( @@ -1403,6 +1278,9 @@ def _restore_snapshot_preferences(self) -> None: self._view_model.apply_snapshot_query(query) except (TypeError, ValueError): self._view_model.reset_snapshot_query() + self._snapshot_panel.set_advanced_visible( + self._settings.value("workspace/snapshotExpanded", False, type=bool) + ) def _save_snapshot_preferences(self) -> None: query = self._view_model.snapshot_query @@ -1432,7 +1310,11 @@ def closeEvent(self, event: QCloseEvent) -> None: ) self._settings.setValue( "workspace/logVisible", - self._log_visible_preference, + self._log_dock.isVisible(), + ) + self._settings.setValue( + "workspace/snapshotExpanded", + self._snapshot_panel.is_advanced_visible, ) self._save_snapshot_preferences() self._settings.sync() @@ -1440,9 +1322,4 @@ def closeEvent(self, event: QCloseEvent) -> None: def showEvent(self, event: QShowEvent) -> None: super().showEvent(event) - if not self._initial_focus_set: - self._initial_focus_set = True - if self._inspector_stack.currentWidget() is self._topic_details: - self._topic_details.focus_payload() - else: - self._observer_tree.focus_search() + self._topic_details.focus_payload() diff --git a/src/topicgate/gui/theme.py b/src/topicgate/gui/theme.py index 05ce533..b5847df 100644 --- a/src/topicgate/gui/theme.py +++ b/src/topicgate/gui/theme.py @@ -3,13 +3,12 @@ LIGHT_THEME = """ -QTabBar#workspaceDestinations::tab { padding: 7px 10px; color: #4b5563; border-bottom: 2px solid transparent; } -QTabBar#workspaceDestinations::tab:selected { color: #202124; border-bottom: 2px solid #405f78; font-weight: bold; } -QTabBar#workspaceDestinations::tab:disabled { color: #89939f; } QWidget { color: #202124; font-size: 13px; } QMainWindow, QDialog, QMessageBox, QWidget#applicationRoot { background: #f3f4f6; color: #202124; } QMessageBox QLabel { background: transparent; color: #202124; } QFrame[workspacePane="true"] { background: #ffffff; border: 1px solid #c8ced6; border-radius: 8px; } +QFrame#observerEmptyState { background: #f8fafc; border: 1px solid #c8ced6; border-radius: 5px; } +QLabel#observerEmptyStateText { background: transparent; color: #4b5563; } QWidget#snapshotPanel, QWidget#snapshotAdvancedContent, QGroupBox#snapshotControls, QGroupBox#snapshotHealthPanel { background: #ffffff; } QScrollArea#snapshotPanelScrollArea { background: #ffffff; } QToolButton#snapshotAdvancedButton { border: 0; background: transparent; font-weight: 650; padding: 3px 5px; } @@ -39,15 +38,11 @@ QLabel#brokerConnectionStatus[connectionState="disconnected"] { color: #4b5563; background: #e9edf1; } QPushButton#brokerHealthSummary, QPushButton#topicHealthBadge { text-align: left; font-weight: 650; padding: 3px 8px; } QPushButton#topicHealthBadge { border-radius: 9px; font-size: 11px; } -QPushButton#brokerHealthSummary { color: #4b5563; background: #f8fafc; border: 1px solid #d8dde3; border-left: 3px solid #89939f; border-radius: 5px; } -QPushButton#brokerHealthSummary:hover { background: #f1f4f7; border-color: #b8c0ca; border-left-color: #6f7b87; } -QPushButton#brokerHealthSummary[healthTone="success"] { color: #11653f; background: #f5fbf8; border-color: #cce7d9; border-left-color: #4ca878; } -QPushButton#brokerHealthSummary[healthTone="success"]:hover { background: #ebf7f1; border-color: #a8dec2; border-left-color: #3f9166; } -QPushButton#brokerHealthSummary[healthTone="problem"] { color: #8f2525; background: #fff8f8; border-color: #ead0d0; border-left-color: #c65353; } -QPushButton#brokerHealthSummary[healthTone="problem"]:hover { background: #fff0f0; border-color: #d7a4a4; border-left-color: #ad4141; } -QPushButton#brokerHealthSummary[healthTone="warning"] { color: #8a4b00; background: #fffaf0; border-color: #eadbbd; border-left-color: #c58a29; } -QPushButton#brokerHealthSummary[healthTone="warning"]:hover { background: #fff3dc; border-color: #e3c68f; border-left-color: #ac741e; } -QPushButton#brokerHealthSummary[healthTone="neutral"] { color: #4b5563; } +QPushButton#brokerHealthSummary { color: #4b5563; background: #fbfcfd; border-color: #c8ced6; } +QPushButton#brokerHealthSummary[healthTone="success"] { color: #11653f; border-left: 3px solid #4ca878; } +QPushButton#brokerHealthSummary[healthTone="problem"] { color: #8f2525; background: #fff8f8; border-left: 3px solid #c65353; } +QPushButton#brokerHealthSummary[healthTone="warning"] { color: #8a4b00; background: #fffaf0; border-left: 3px solid #c58a29; } +QPushButton#brokerHealthSummary[healthTone="neutral"] { color: #4b5563; border-left: 3px solid #89939f; } QPushButton#topicHealthBadge[healthTone="success"] { color: #11653f; background: #dcf7e9; border-color: #a8dec2; } QPushButton#topicHealthBadge[healthTone="problem"] { color: #8f2525; background: #fff0f0; border-color: #d7a4a4; } QPushButton#topicHealthBadge[healthTone="warning"] { color: #8a4b00; background: #fff0d5; border-color: #e3c68f; } diff --git a/src/topicgate/presentation/snapshot_presentation.py b/src/topicgate/presentation/snapshot_presentation.py index 48318af..bb94edf 100644 --- a/src/topicgate/presentation/snapshot_presentation.py +++ b/src/topicgate/presentation/snapshot_presentation.py @@ -152,6 +152,6 @@ def topic_omission_notice( else: reason = "the active snapshot bounds omit it" return ( - "Current value available, but excluded from snapshot values and counts because " + "Current value available, but omitted from the observer tree because " f"{reason}." ) diff --git a/tests/test_event_history_gui.py b/tests/test_event_history_gui.py index 26a4519..f6f3a13 100644 --- a/tests/test_event_history_gui.py +++ b/tests/test_event_history_gui.py @@ -2,15 +2,13 @@ from threading import Event from unittest.mock import Mock -from PySide6.QtWidgets import QApplication, QPushButton +from PySide6.QtWidgets import QApplication from topicgate.app.services.history_retention_service import HistoryRetentionService from topicgate.app.services.topic_history_service import TopicHistoryService from topicgate.core.models.history_recording import HistoryRecordingStatus from topicgate.core.models.history_retention import HistoryRetentionPolicy, HistoryUsage -from topicgate.gui.components.event_history_widget import EventHistoryWidget from topicgate.gui.components.stored_observations_dialog import StoredObservationsDialog -from topicgate.gui.components.workspace_pane import WorkspacePane from topicgate.gui.main_view_model import MainViewModel from test_gui import FakeGuiRepository, runtime_for from test_observation_history_repository import event @@ -28,31 +26,18 @@ def make_page(broker): lambda owner: HistoryRecordingStatus(owner, dropped=2)).query(broker, "#", limit=1) -def test_event_history_reuses_the_workspace_pane_design(): - app = QApplication.instance() or QApplication([]) - widget = EventHistoryWidget(MainViewModel(runtime_for(FakeGuiRepository()))) - - assert isinstance(widget, WorkspacePane) - assert widget.property("workspacePane") is True - assert widget.heading.text() == "Message history" - assert widget.header_layout.indexOf(widget.heading) == 0 - - widget.close() - app.processEvents() - - def test_event_page_separates_latest_state_and_resets_cursor_on_edits(): app = QApplication.instance() or QApplication([]) vm = MainViewModel(runtime_for(FakeGuiRepository())) dialog = StoredObservationsDialog(vm) - widget = EventHistoryWidget(vm) - assert dialog.tabs.tabText(0) == "Latest stored values" - assert "Event history" not in [dialog.tabs.tabText(i) for i in range(dialog.tabs.count())] + widget = dialog.event_history + assert dialog.tabs.tabText(0) == "Latest stored state" + assert dialog.tabs.tabText(1) == "Event history" vm.event_history_result = make_page(widget.broker.currentData()) vm.event_history_changed.emit() assert widget.results.rowCount() == 1 assert widget.next_page.isEnabled() - assert "recording" not in widget.status.text() + assert "disabled" in widget.status.text() assert "dropped 2" in widget.status.text() assert "incomplete" in widget.limitations.toPlainText() widget.results.selectRow(0) @@ -110,239 +95,3 @@ async def test_new_page_replaces_previous_page_and_failures_are_visible(): assert vm.event_history_result is None assert vm.event_history_error == "Invalid history cursor." app.processEvents() - - -async def test_recording_status_precedes_search_and_enable_preserves_retention(): - app = QApplication.instance() or QApplication([]) - runtime = runtime_for(FakeGuiRepository()) - vm = MainViewModel(runtime) - dialog = StoredObservationsDialog(vm) - widget = EventHistoryWidget(vm) - broker = widget.broker.currentData() - runtime.get_history_retention_policy = Mock(return_value=HistoryRetentionPolicy()) - runtime.get_history_usage = Mock(return_value=HistoryUsage(broker, 0, 0, None, None, 0)) - runtime.get_history_recording_status = Mock(return_value=HistoryRecordingStatus(broker)) - runtime.update_history_retention_policy = Mock() - runtime.set_history_recording = Mock() - await vm.load_history_settings(broker) - assert vm.event_history_result is None - assert "Recording disabled" in widget.recording_status.text() - assert widget.enable_recording.isEnabled() - widget.request_recording_status() - vm.operation_state_changed.emit() - assert not widget.enable_recording.isEnabled() - assert "Loading" in widget.recording_status.text() - await vm.load_history_settings(broker) - requested = [] - widget.recording_requested.connect(lambda broker, enabled: requested.append((broker, enabled))) - widget.enable_recording.click() - assert requested == [(broker, True)] - runtime.get_history_recording_status.return_value = HistoryRecordingStatus(broker, enabled=True) - await vm.set_history_recording(broker, True) - runtime.set_history_recording.assert_called_once_with(broker, True) - runtime.update_history_retention_policy.assert_not_called() - assert "Recording enabled" in widget.recording_status.text() - assert widget.enable_recording.isEnabled() - assert widget.enable_recording.isChecked() - widget.enable_recording.click() - assert requested[-1] == (broker, False) - runtime.get_history_recording_status.return_value = HistoryRecordingStatus(broker) - await vm.set_history_recording(broker, False) - assert runtime.set_history_recording.call_args.args == (broker, False) - runtime.update_history_retention_policy.assert_not_called() - assert not widget.enable_recording.isChecked() - widget.broker.setCurrentIndex(1) - assert not widget.enable_recording.isEnabled() - assert "Loading" in widget.recording_status.text() - dialog.close() - app.processEvents() - - -async def test_recording_failure_is_visible_without_claiming_success(): - app = QApplication.instance() or QApplication([]) - runtime = runtime_for(FakeGuiRepository()) - vm = MainViewModel(runtime) - dialog = StoredObservationsDialog(vm) - widget = EventHistoryWidget(vm) - broker = widget.broker.currentData() - runtime.set_history_recording = Mock(side_effect=RuntimeError("unavailable")) - await vm.set_history_recording(broker, True) - assert "could not be changed" in widget.recording_status.text() - assert not widget.enable_recording.isEnabled() - dialog.close() - app.processEvents() - - -def test_history_units_unlimited_and_advanced_pruning_round_trip(): - app = QApplication.instance() or QApplication([]) - vm = MainViewModel(runtime_for(FakeGuiRepository())) - dialog = StoredObservationsDialog(vm) - widget = dialog.history_settings - vm.history_settings_broker = widget.broker.currentData() - vm.history_policy = HistoryRetentionPolicy() - widget.render() - assert widget.draft_policy() == vm.history_policy - assert not widget.advanced_content.isHidden() - assert widget.findChild(QPushButton, "historyAdvancedPruning") is None - widget.unlimited["max_age_seconds"].setChecked(True) - assert widget.draft_policy().max_age_seconds is None - assert not widget.quantities["max_age_seconds"].isEnabled() - widget.unlimited["max_age_seconds"].setChecked(False) - widget.fields["max_age_seconds"].setText("2") - widget.quantities["max_age_seconds"].unit.setCurrentText("Days") - assert widget.draft_policy().max_age_seconds == 172800 - widget.fields["max_payload_bytes"].setText("not a number") - assert not widget.save.isEnabled() - previous_broker = widget.broker.currentData() - widget.broker.setCurrentIndex(1) - vm.history_settings_broker = widget.broker.currentData() - vm.history_policy = None - vm.history_recording_status = HistoryRecordingStatus(previous_broker, enabled=True) - widget.render() - assert not widget.enabled.isChecked() - assert not widget.enabled.isEnabled() - assert "unavailable for this broker" in widget.summary.text() - dialog.close() - app.processEvents() - - -async def test_workspace_history_navigation_and_recording_are_scoped(tmp_path): - from PySide6.QtCore import QSettings - from topicgate.gui.main_window import MainWindow - - app = QApplication.instance() or QApplication([]) - runtime = runtime_for(FakeGuiRepository()) - vm = MainViewModel(runtime) - enabled_by_broker = {} - 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) - ) - runtime.get_history_recording_status = Mock( - side_effect=lambda broker: HistoryRecordingStatus( - broker, enabled=enabled_by_broker.get(broker, False) - ) - ) - runtime.set_history_recording = Mock( - side_effect=lambda broker, enabled: enabled_by_broker.update({broker: enabled}) - ) - runtime.update_history_retention_policy = Mock() - runtime.get_topic_history = Mock(side_effect=lambda broker, *a, **kw: make_page(broker)) - window = MainWindow(vm, QSettings(str(tmp_path / "gui.ini"), QSettings.Format.IniFormat)) - tabs = window._destination_tabs - widget = window._event_history - - async def finish_operations(): - await asyncio.gather(*tuple(window._operation_tasks)) - - try: - assert [tabs.tabText(i) for i in range(tabs.count())] == [ - "Health", "Selected", "Snapshot", "History" - ] - assert not tabs.expanding() - assert not tabs.isTabEnabled(1) - tabs.setCurrentIndex(3) - await finish_operations() - assert window._stored_observations_dialog is None - assert widget.broker.currentData() == vm.active_broker_profile.id - assert "Recording disabled" in widget.recording_status.text() - runtime.set_history_recording.assert_not_called() - runtime.get_topic_history.assert_not_called() - - widget.enable_recording.click() - assert not widget.enable_recording.isEnabled() - widget.enable_recording.click() - await finish_operations() - runtime.set_history_recording.assert_called_once_with(vm.active_broker_profile.id, True) - assert widget.enable_recording.isChecked() - widget.enable_recording.click() - await finish_operations() - assert not widget.enable_recording.isChecked() - - widget.broker.setCurrentIndex(1) - await finish_operations() - other = widget.broker.currentData() - widget.enable_recording.click() - await finish_operations() - assert runtime.set_history_recording.call_args.args == (other, True) - runtime.update_history_retention_policy.assert_not_called() - widget.search.click() - await finish_operations() - assert widget.results.rowCount() == 1 - - tabs.setCurrentIndex(2) - tabs.setCurrentIndex(3) - await finish_operations() - assert widget.broker.currentData() == vm.active_broker_profile.id - assert widget.results.rowCount() == 0 - assert not widget.enable_recording.isChecked() - assert not widget.next_page.isEnabled() - assert window._context_panel.isHidden() - finally: - await window.cancel_pending_operations() - window.close() - app.processEvents() - - -async def test_late_recording_status_cannot_enable_controls_for_another_broker(): - app = QApplication.instance() or QApplication([]) - runtime = runtime_for(FakeGuiRepository()) - vm = MainViewModel(runtime) - widget = EventHistoryWidget(vm) - first = widget.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) - widget.broker.setCurrentIndex(1) - assert not widget.enable_recording.isEnabled() - await vm.load_history_settings(widget.broker.currentData()) - release.set() - await task - assert widget.enable_recording.isEnabled() - assert not widget.enable_recording.isChecked() - assert "Recording disabled" in widget.recording_status.text() - finally: - release.set() - await task - widget.close() - app.processEvents() - - -def test_history_mode_switch_preserves_page_selection_and_payload(): - app = QApplication.instance() or QApplication([]) - vm = MainViewModel(runtime_for(FakeGuiRepository())) - widget = EventHistoryWidget(vm) - page = make_page(widget.broker.currentData()) - vm.event_history_result = page - widget.render() - widget.results.selectRow(0) - selected = widget.results.currentRow() - queries, recordings = [], [] - widget.query_requested.connect(lambda *args: queries.append(args)) - widget.recording_requested.connect(lambda *args: recordings.append(args)) - widget.set_advanced_mode(False) - assert vm.event_history_result is page - assert widget.results.currentRow() == selected - assert "Observation " not in widget.payload.toPlainText() - assert "synthetic" in widget.payload.toPlainText() - assert "dropped 2" not in widget.status.text() - assert not widget.limitations.isHidden() - widget.set_advanced_mode(True) - assert "Observation " in widget.payload.toPlainText() - assert "dropped 2" in widget.status.text() - assert queries == recordings == [] - widget.close() - app.processEvents() diff --git a/tests/test_gui.py b/tests/test_gui.py index 165795d..a94024a 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -29,7 +29,6 @@ QPushButton, QPlainTextEdit, QScrollArea, - QSizePolicy, QStackedWidget, QSplitter, QSpinBox, @@ -249,34 +248,11 @@ def test_health_action_opens_broker_scoped_inspector() -> None: application.processEvents() -def test_history_action_opens_event_history() -> None: - application = QApplication.instance() or QApplication([]) - repository = FakeGuiRepository() - window = MainWindow( - MainViewModel(runtime_for(repository), repository.state.topic) - ) - - action = window.findChild(QAction, "historyAction") - assert action is not None - assert action.text() == "History" - assert action in window._view_menu.actions() - - action.trigger() - application.processEvents() - - stack = window.findChild(QStackedWidget, "inspectorStack") - assert stack.currentIndex() == 2 - assert stack.currentWidget().widget() is window._event_history - window.close() - application.processEvents() - - def test_settings_health_and_observation_tabs_reuse_visible_topic_tab_style() -> None: application = QApplication.instance() or QApplication([]) repository = FakeGuiRepository() view_model = MainViewModel(runtime_for(repository), repository.state.topic) window = MainWindow(view_model) - window._show_topic_details() settings_tabs = window.findChild(QTabWidget, "topicSettingsTabs") assert settings_tabs is not None assert settings_tabs.tabBar().objectName() == "topicSettingsTabs" @@ -303,7 +279,7 @@ def test_settings_health_and_observation_tabs_reuse_visible_topic_tab_style() -> ) assert observation_tabs is not None assert observation_tabs.tabBar().objectName() == "storedObservationsPages" - assert not observation_tabs.tabBar().expanding() + assert observation_tabs.tabBar().expanding() assert "QTabBar#topicSettingsTabs::tab:selected" in LIGHT_THEME assert "QTabBar#healthTabs::tab:selected" in LIGHT_THEME assert "QTabBar#storedObservationsPages::tab:selected" in LIGHT_THEME @@ -334,87 +310,6 @@ def test_topic_expectation_editor_creates_utf8_rule() -> None: created = management.create_expectation.call_args.args[0] assert created.name == "Temperature" assert created.condition.expected_value == b"21.5" - assert "saved" in editor.findChild(QLabel, "expectationSaveFeedback").text() - window.close() - application.processEvents() - - -def test_expectation_directory_includes_topic_rules_and_preserves_navigation_scope(tmp_path) -> None: - from topicgate.core.models.health import TopicTarget - application = QApplication.instance() or QApplication([]) - repository = FakeGuiRepository() - runtime = runtime_for(repository) - rule = HealthExpectation( - uuid4(), 1, True, HealthSeverity.WARNING, - TopicTarget(runtime.active_broker.id, repository.state.topic), - EqualCondition(b"21.5"), frozenset(), "Kitchen temperature", - ) - management = MagicMock() - management.list_expectations.return_value = (rule,) - vm = MainViewModel(runtime, expectation_management_service=management) - window = MainWindow(vm, QSettings(str(tmp_path / "gui.ini"), QSettings.Format.IniFormat)) - window._destination_tabs.setCurrentIndex(0) - inspector = window._health_inspector - inspector._tabs.setCurrentIndex(1) - table = inspector.findChild(QTableWidget, "healthExpectationDirectory") - assert table.rowCount() == 1 - assert table.item(0, 0).text() == rule.name - table.selectRow(0) - inspector.render() - assert inspector._open_rule.isEnabled() - inspector._open_rule.click() - assert vm.topic == repository.state.topic - assert window._topic_expectations._selected_id == rule.expectation_id - assert runtime.active_broker.name in window._topic_expectations._context.text() - assert window._destination_tabs.currentIndex() == 1 - window._destination_tabs.setCurrentIndex(0) - assert inspector._tabs.currentIndex() == 1 - inspector._expectation_scope.setCurrentIndex(1) - assert table.rowCount() == 0 - assert not inspector._open_rule.isEnabled() - window.close() - application.processEvents() - - -async def test_subscription_apply_feedback_and_small_window_layout(tmp_path) -> None: - application = QApplication.instance() or QApplication([]) - repository = FakeGuiRepository() - vm = MainViewModel(runtime_for(repository), repository.state.topic) - window = MainWindow(vm, QSettings(str(tmp_path / "gui.ini"), QSettings.Format.IniFormat)) - window.resize(1024, 640) - window.show() - window._show_topic_expectations() - application.processEvents() - assert window.width() == 1024 - assert window.height() == 640 - from PySide6.QtGui import QShowEvent - - window._observer_tree.focus_search() - window.showEvent(QShowEvent()) - assert window.focusWidget() is window._observer_tree._search_edit - await window._save_subscription(repository.subscriptions[0].topic_filter, repository.subscriptions[0]) - assert "Subscription applied" in window.findChild(QLabel, "subscriptionApplyFeedback").text() - window.close() - application.processEvents() - - -def test_observer_filter_keeps_subscription_rows_and_hidden_values(tmp_path) -> None: - application = QApplication.instance() or QApplication([]) - repository = FakeGuiRepository() - repository.subscriptions = (Subscription(repository.state.topic),) - vm = MainViewModel(runtime_for(repository), repository.state.topic) - window = MainWindow(vm, QSettings(str(tmp_path / "gui.ini"), QSettings.Format.IniFormat)) - vm.apply_snapshot_query(SnapshotQuery(topic_filter="elsewhere/#")) - pane = window._observer_tree - assert "subscriptions" not in pane._scope.text() - assert "0 values" not in pane._scope.text() - assert pane.findChild(QWidget, "observerEmptyState") is None - assert "excluded from snapshot values and counts" in vm.topic_detail.snapshot_scope_note - assert any(item.text().startswith("Subscription:") for item in pane._items.values()) - pane._search_edit.setText("does-not-exist") - assert "No displayed topics" in pane._search_status.text() - pane._search_edit.clear() - assert pane._search_status.isHidden() window.close() application.processEvents() @@ -469,9 +364,10 @@ def test_snapshot_panel_applies_clears_and_renders_health() -> None: assert pane.header_layout.indexOf(pane.heading) == 0 assert pane.findChild(QToolButton, "snapshotToggleButton") is None assert pane.findChild(QWidget, "snapshotContent") is None - assert advanced is None + assert not pane.is_advanced_visible + assert not advanced.isChecked() assert not controls.isHidden() - assert not advanced_content.isHidden() + assert advanced_content.isHidden() assert pane.findChild(QLabel, "snapshotSummaryConnection").text() == ( "Disconnected" ) @@ -480,6 +376,8 @@ def test_snapshot_panel_applies_clears_and_renders_health() -> None: "reconnectObserveButton", ).accessibleName() == "Reconnect & observe" assert pane.heading.text() == "Broker snapshot" + assert advanced.text() == "Advanced" + assert pane.header_layout.indexOf(advanced) >= 0 assert pane.findChild( QPushButton, "reconnectObserveButton", @@ -488,7 +386,11 @@ def test_snapshot_panel_applies_clears_and_renders_health() -> None: pane.findChild(QLabel, "snapshotSummaryConnection") ) + advanced.setFocus() + QTest.keyClick(advanced, Qt.Key.Key_Space) + assert pane.is_advanced_visible assert not advanced_content.isHidden() + assert advanced.text() == "Hide advanced" pane.findChild(QLineEdit, "snapshotTopicFilter").setText("home/#") pane.findChild(QLineEdit, "snapshotMaximumAge").setText("10.5") pane.findChild(QSpinBox, "snapshotResultLimit").setValue(12) @@ -534,8 +436,9 @@ def test_snapshot_panel_applies_clears_and_renders_health() -> None: "Limited" ) + pane.set_advanced_visible(False) assert pane.query == SnapshotQuery("home/#", 10.5, 12, 512) - assert not advanced_content.isHidden() + assert advanced_content.isHidden() assert not controls.isHidden() pane.findChild(QPushButton, "clearSnapshotFiltersButton").click() @@ -790,13 +693,11 @@ def test_settings_button_reveals_subscription_and_expectation_settings() -> None ) settings.clear() window = MainWindow(view_model, settings) - window._show_topic_details() context = window.findChild(QWidget, "contextPanel") edit_button = window.findChild(QToolButton, "topicEditButton") assert context is not None assert edit_button is not None - assert window.findChild(QPushButton, "topicExpectationsButton") is None assert context.isHidden() assert edit_button.text() == "Settings" assert not edit_button.icon().isNull() @@ -808,14 +709,7 @@ def test_settings_button_reveals_subscription_and_expectation_settings() -> None edit_button.click() assert not context.isHidden() - assert edit_button.text() == "Close settings" - assert len( - [ - button - for button in window.findChildren(QPushButton) - if button.text() == "Close settings" - ] - ) == 0 + assert edit_button.text() == "Settings" assert context.findChild(QWidget, "topicExpectationEditor") is not None assert context.findChild(QWidget, "topicPublishPane") is None assert context.findChild(QPushButton, "revertSubscriptionButton") is None @@ -840,7 +734,6 @@ def test_workspace_headers_and_primary_controls_share_rows() -> None: MainViewModel(runtime_for(repository), repository.state.topic), settings, ) - window._show_topic_details() window.findChild(QToolButton, "topicEditButton").click() window.show() application.processEvents() @@ -1071,12 +964,12 @@ def test_desktop_persists_snapshot_preferences_and_focuses_search() -> None: settings.clear() first = MainWindow(MainViewModel(runtime_for(FakeGuiRepository())), settings) first._apply_snapshot_query(SnapshotQuery("devices/#", 12.0, 9, 256)) - first._advanced_mode_action.setChecked(True) + first._snapshot_panel.set_advanced_visible(True) first.close() second = MainWindow(MainViewModel(runtime_for(FakeGuiRepository())), settings) assert second._view_model.snapshot_query == SnapshotQuery("devices/#", 12.0, 9, 256) - assert second._advanced_mode_action.isChecked() + assert second._snapshot_panel.is_advanced_visible second.show() application.processEvents() second._focus_topic_search_action.trigger() @@ -1406,7 +1299,6 @@ def test_health_refreshes_while_inspector_is_closed_without_navigation() -> None expectation_management_service=management, ) ) - window._show_topic_details() timer = window.findChild(QObject, "healthRefreshTimer") timer.setInterval(0) @@ -1518,7 +1410,7 @@ def test_health_inspector_renders_latest_finding_delta() -> None: application.processEvents() -def test_inspector_defaults_to_health_and_preserves_restored_selection() -> None: +def test_inspector_starts_from_selection_and_owns_one_snapshot() -> None: application = QApplication.instance() or QApplication([]) repository = FakeGuiRepository() empty_settings = QSettings( @@ -1534,8 +1426,7 @@ def test_inspector_defaults_to_health_and_preserves_restored_selection() -> None stack = snapshot_window.findChild(QStackedWidget, "inspectorStack") snapshots = snapshot_window.findChildren(SnapshotPanel) scroll = snapshot_window.findChild(QScrollArea, "snapshotPanelScrollArea") - assert stack.currentWidget() is snapshot_window._health_inspector - assert snapshot_window._destination_tabs.currentIndex() == 0 + assert stack.currentIndex() == 0 assert len(snapshots) == 1 assert snapshots[0] is snapshot_window._snapshot_panel assert snapshot_window._observer_tree.findChildren(SnapshotPanel) == [] @@ -1555,10 +1446,7 @@ def test_inspector_defaults_to_health_and_preserves_restored_selection() -> None assert details_window.findChild( QStackedWidget, "inspectorStack", - ).currentWidget() is details_window._health_inspector - assert details_window._view_model.topic == repository.state.topic - details_window._destination_tabs.setCurrentIndex(1) - assert details_window._inspector_stack.currentWidget() is details_window._topic_details + ).currentWidget() is details_window._topic_details snapshot_window.close() details_window.close() @@ -1584,7 +1472,7 @@ def test_default_window_uses_isolated_settings( application.processEvents() -def test_selected_subscription_shows_value_outside_snapshot_scope() -> None: +def test_selected_subscription_shows_value_omitted_from_observer_tree() -> None: application = QApplication.instance() or QApplication([]) repository = FakeGuiRepository() topic = repository.state.topic @@ -1592,7 +1480,6 @@ def test_selected_subscription_shows_value_outside_snapshot_scope() -> None: view_model = MainViewModel(runtime_for(repository), topic) window = MainWindow(view_model) - window._show_topic_details() window._apply_snapshot_query(SnapshotQuery(topic_filter="other/#")) tree = window.findChild(QTreeView, "observerTree") @@ -1602,7 +1489,7 @@ def test_selected_subscription_shows_value_outside_snapshot_scope() -> None: node for node in view_model.topic_tree if node.path == "home" ).children[0].children[0] assert selected_node.path == topic - assert selected_node.is_observed + assert not selected_node.is_observed assert tree is not None assert decoded.toPlainText() == "21.5" assert notice.isVisibleTo(window) @@ -1617,8 +1504,6 @@ def test_snapshot_panel_exposes_active_bounds_and_omitted_count() -> None: view_model = MainViewModel(runtime_for(repository)) window = MainWindow(view_model) - window._advanced_mode_action.setChecked(True) - window._show_snapshot() window._apply_snapshot_query(SnapshotQuery("home/#", None, 1, 256)) window._snapshot_panel.render_health( replace(view_model.snapshot_health, omitted_count=1) @@ -1638,7 +1523,6 @@ def test_inspector_navigation_preserves_snapshot_and_tree_editing_state() -> Non application = QApplication.instance() or QApplication([]) repository = FakeGuiRepository() window = MainWindow(MainViewModel(runtime_for(repository))) - window._advanced_mode_action.setChecked(True) stack = window.findChild(QStackedWidget, "inspectorStack") filter_edit = window.findChild(QLineEdit, "snapshotTopicFilter") search_edit = window._observer_tree._search_edit @@ -1660,7 +1544,13 @@ def test_inspector_navigation_preserves_snapshot_and_tree_editing_state() -> Non window.findChild(QToolButton, "topicEditButton").setChecked(True) assert not window._context_panel.isHidden() - window._destination_tabs.setCurrentIndex(2) + inspect = window.findChild(QPushButton, "inspectSnapshotButton") + requests: list[bool] = [] + window._broker_connection.inspect_snapshot_requested.connect( + lambda: requests.append(True) + ) + inspect.click() + assert requests == [True] assert stack.currentIndex() == 0 assert window._view_model.topic == repository.state.topic assert window._context_panel.isHidden() @@ -1689,8 +1579,6 @@ def test_background_updates_preserve_the_explicit_inspector_view() -> None: repository = FakeGuiRepository() view_model = MainViewModel(runtime_for(repository), repository.state.topic) window = MainWindow(view_model) - window._advanced_mode_action.setChecked(True) - window._show_topic_details() stack = window.findChild(QStackedWidget, "inspectorStack") assert stack.currentWidget() is window._topic_details @@ -1719,8 +1607,6 @@ async def test_reconnect_preserves_the_current_inspector_view() -> None: view_model = MainViewModel(runtime_for(repository), repository.state.topic) view_model._snapshot_service._sleep = AsyncMock() window = MainWindow(view_model) - window._advanced_mode_action.setChecked(True) - window._show_topic_details() stack = window.findChild(QStackedWidget, "inspectorStack") await view_model.reconnect_and_observe() @@ -1739,7 +1625,23 @@ async def test_reconnect_preserves_the_current_inspector_view() -> None: application.processEvents() -def test_light_theme_keeps_dialog_text_readable() -> None: +def test_observer_empty_states_explain_recovery_actions() -> None: + application = QApplication.instance() or QApplication([]) + pane = ObserverTreePane() + pane.render_empty_state("disconnected", (), False, False, False) + assert "No subscriptions" in pane.findChild(QLabel, "observerEmptyStateText").text() + + pane.render_empty_state("connected", (Subscription("devices/#"),), True, False, False) + action = pane.findChild(QToolButton, "observerEmptyStateAction") + assert "current snapshot filters" in pane.findChild( + QLabel, "observerEmptyStateText" + ).text() + assert action.text() == "Clear filters" + pane.deleteLater() + application.processEvents() + + +def test_light_theme_keeps_dialog_and_empty_state_text_readable() -> None: application = QApplication.instance() or QApplication([]) apply_light_theme(application) @@ -1749,6 +1651,8 @@ def test_light_theme_keeps_dialog_text_readable() -> None: assert palette.color(QPalette.ColorRole.WindowText).name() == "#202124" assert palette.color(QPalette.ColorRole.ButtonText).name() == "#202124" assert "QMessageBox QLabel" in LIGHT_THEME + assert "QFrame#observerEmptyState" in LIGHT_THEME + assert "QLabel#observerEmptyStateText" in LIGHT_THEME assert "QTabBar#topicDetailsMode::tab" in LIGHT_THEME assert "QTabBar#topicDetailsMode::tab:selected" in LIGHT_THEME assert "color: #ffffff; background: #405d7a" in LIGHT_THEME @@ -2105,7 +2009,6 @@ def test_main_window_builds_three_pane_workspace_and_collapsible_log() -> None: settings.clear() window = MainWindow(view_model, settings) - window._advanced_mode_action.setChecked(True) splitter = window.findChild(QSplitter, "workspaceSplitter") log_dock = window.findChild(QDockWidget, "logConsoleDock") @@ -2134,13 +2037,10 @@ def test_main_window_builds_three_pane_workspace_and_collapsible_log() -> None: status = window.findChild(QLabel, "brokerConnectionStatus") assert status.text() == "Connected" assert status.accessibleName() == "MQTT connection status" - heading_icon = window.findChild(QLabel, "brokerHeadingIcon") - assert heading_icon.accessibleName() == "Broker" - assert window._broker_connection.header_layout.indexOf(heading_icon) == 0 assert window._broker_connection.header_layout.indexOf( window._broker_connection.heading - ) == 1 - assert window._broker_connection.header_layout.indexOf(status) == 2 + ) == 0 + assert window._broker_connection.header_layout.indexOf(status) == 1 assert window.findChild(QLabel, "activeBrokerEndpoint") is None assert window.findChild(QToolButton, "brokerSettingsButton") is None assert window.findChild(QWidget, "applicationHeader") is None @@ -2181,7 +2081,7 @@ def test_compact_broker_pane_exposes_switching_and_connection_actions() -> None: selector = window.findChild(QComboBox, "connectionBrokerSelector") lifecycle = window.findChild(QPushButton, "brokerLifecycleButton") health = window.findChild(QPushButton, "brokerHealthSummary") - management = window.findChild(QToolButton, "manageBrokersButton") + inspect_snapshot = window.findChild(QPushButton, "inspectSnapshotButton") profile_menu = window.findChild(QMenu, "brokerProfileSelectorMenu") assert [selector.itemText(index) for index in range(selector.count())] == [ @@ -2192,36 +2092,22 @@ def test_compact_broker_pane_exposes_switching_and_connection_actions() -> None: assert lifecycle.text() == "Disconnect" assert lifecycle.isEnabled() assert window.findChild(QPushButton, "brokerDisconnectButton") is None - assert management.isEnabled() - assert management.accessibleName() == "Manage broker profiles" - assert not management.icon().isNull() - assert not window.findChild(QLabel, "brokerHeadingIcon").pixmap().isNull() - assert window.findChild(QPushButton, "inspectSnapshotButton") is None + assert inspect_snapshot.isEnabled() + assert inspect_snapshot.accessibleName() == "Inspect broker snapshot" window.resize(window.minimumSize()) window.show() application.processEvents() assert selector.isVisible() assert health.isVisible() - assert management.isVisible() + assert inspect_snapshot.isVisible() assert lifecycle.isVisible() assert lifecycle.text() == "Disconnect" - assert lifecycle.property("primary") is False - assert ( - selector.geometry().top() - == management.geometry().top() - == lifecycle.geometry().top() - ) - assert health.geometry().top() > selector.geometry().top() - assert health.geometry().left() == selector.geometry().left() - assert health.geometry().right() == lifecycle.geometry().right() - assert ( - selector.sizePolicy().horizontalPolicy() - == QSizePolicy.Policy.Expanding - ) - assert ( - management.sizePolicy().horizontalPolicy() - == QSizePolicy.Policy.Fixed - ) + assert selector.geometry().top() == lifecycle.geometry().top() + assert health.geometry().top() == inspect_snapshot.geometry().top() + assert selector.geometry().right() == health.geometry().right() + assert lifecycle.geometry().left() == inspect_snapshot.geometry().left() + assert lifecycle.width() == inspect_snapshot.width() + assert selector.geometry().top() < health.geometry().top() assert [ action.defaultWidget() .findChild(QToolButton, "selectBrokerProfileButton") @@ -2241,7 +2127,7 @@ def test_compact_broker_pane_exposes_switching_and_connection_actions() -> None: assert delete_button.text() == "Delete" assert not delete_button.icon().isNull() assert window.findChild(QAction, "addBrokerProfilePaneAction").text() == ( - "Add Broker" + "+ Add Broker" ) assert window.findChild(QToolButton, "manageBrokerProfilesButton") is None assert window.findChild(QMenu, "editBrokerProfilePaneMenu") is None @@ -2290,7 +2176,6 @@ def test_broker_pane_lifecycle_button_connects_or_disconnects() -> None: view_model._connection_status = "disconnected" pane.render(view_model) assert lifecycle.text() == "Connect" - assert lifecycle.property("primary") is True lifecycle.click() assert requests == ["disconnect", "connect"] @@ -2332,14 +2217,15 @@ def test_topic_metadata_hides_advanced_fields_until_requested() -> None: messages = pane.findChild(QLabel, "messageCountLabel") raw = pane.findChild(QPlainTextEdit, "rawPayload") - assert advanced is None + assert advanced.text() == "Advanced" assert source.isHidden() assert raw.isHidden() assert not state.isHidden() assert not messages.isHidden() - pane.set_advanced_mode(True) + advanced.click() + assert advanced.text() == "Hide advanced" assert not source.isHidden() assert not raw.isHidden() @@ -2397,7 +2283,7 @@ def test_topic_details_switches_between_payload_and_embedded_publish() -> None: assert pane.heading.toolTip() == repository.state.topic assert publish_hint.isHidden() - pane.set_advanced_mode(True) + advanced.click() assert not raw.isHidden() publish_payload.setPlainText("outgoing draft") modes.setCurrentIndex(1) @@ -2413,7 +2299,7 @@ def test_topic_details_switches_between_payload_and_embedded_publish() -> None: assert not decoded.isHidden() assert not raw.isHidden() - assert not raw.isHidden() + assert advanced.isChecked() assert pane.heading.toolTip() == repository.state.topic assert publish_payload.toPlainText() == "outgoing draft" pane.close() @@ -3158,7 +3044,7 @@ async def scenario() -> None: assert repository.broker_configurations == [local_profile.config] assert broker_repository.get_profile().id == local_profile.id - assert stack.currentWidget() is window._health_inspector + assert stack.currentIndex() == 0 assert "Do you want to switch" in question.call_args.args[2] assert "shutting down the current MQTT connection" in question.call_args.args[2] window.close() @@ -3176,7 +3062,6 @@ def test_cancelled_broker_switch_and_profile_updates_preserve_topic_details() -> repository.state.topic, ) window = MainWindow(view_model) - window._show_topic_details() stack = window.findChild(QStackedWidget, "inspectorStack") active = view_model.active_broker_profile inactive = view_model.broker_profiles[1] @@ -3293,270 +3178,3 @@ async def scenario() -> None: application.processEvents() await scenario() - - -@pytest.mark.parametrize("saved_mode", [None, False, True]) -def test_global_advanced_mode_defaults_and_persists(tmp_path, saved_mode) -> None: - application = QApplication.instance() or QApplication([]) - settings = QSettings(str(tmp_path / "mode.ini"), QSettings.Format.IniFormat) - settings.setValue("workspace/snapshotExpanded", True) - if saved_mode is not None: - settings.setValue("workspace/advancedMode", saved_mode) - window = MainWindow(MainViewModel(runtime_for(FakeGuiRepository())), settings) - assert window._advanced_mode_action.isCheckable() - assert window._advanced_mode_action in window._view_menu.actions() - assert window._advanced_mode_action.isChecked() is bool(saved_mode) - window._advanced_mode_action.trigger() - expected = not bool(saved_mode) - assert settings.value("workspace/advancedMode", type=bool) is expected - 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 - restored.close() - application.processEvents() - - -def test_global_mode_visibility_fallback_and_stale_routes() -> None: - application = QApplication.instance() or QApplication([]) - repository = FakeGuiRepository() - vm = MainViewModel(runtime_for(repository), repository.state.topic) - window = MainWindow(vm, diagnostic_profile_editor=MagicMock()) - window.show() - application.processEvents() - broker_id = vm.active_broker_profile.id - topic = vm.topic - 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 - for action in (window._stored_observations_action, window._console_action, - window._diagnostic_profiles_action): - assert action.isVisible() is advanced - assert action.isEnabled() is advanced - assert window._advanced_indicator.isVisible() is advanced - assert window._health_inspector._tabs.isTabVisible(3) is advanced - assert window._health_inspector._delete_history_button.isHidden() is not advanced - assert window._event_history.limit.isHidden() is not advanced - assert window._event_history.results.isColumnHidden(2) is not advanced - assert window._event_history.results.isColumnHidden(3) is not advanced - assert window._event_history.results.isColumnHidden(4) is False - assert window._subscription_settings._retain_handling.isHidden() is not advanced - assert window._topic_expectations._log_action.isHidden() is not advanced - assert window._health_inspector._broker_expectations._revision.isHidden() is not advanced - window._show_topic_details() - vm.state_changed.emit() - assert window._topic_details._raw_payload.isHidden() is not advanced - assert window._topic_details._metadata.source.isHidden() is not advanced - assert not window._topic_details._decoded_payload.isHidden() - assert not window._event_history.enable_recording.isHidden() - if advanced: - window._show_snapshot() - window._health_inspector._tabs.setCurrentIndex(3) - window._console_action.trigger() - else: - assert window._health_inspector._tabs.currentIndex() != 3 - window._show_stored_observations() - window._stored_observations_action.trigger() - window._show_diagnostic_profiles() - window._diagnostic_profiles_action.trigger() - window._console_action.trigger() - window._navigate(2) - assert window._inspector_stack.currentWidget() is window._health_inspector - assert window._stored_observations_dialog is None - assert window._diagnostic_profile_editor_window is None - assert not window._log_dock.isVisible() - assert not window._log_dock.toggleViewAction().isEnabled() - assert not window._log_dock.toggleViewAction().isVisible() - window._health_inspector._tabs.setCurrentIndex(3) - assert window._health_inspector._tabs.currentIndex() == 0 - assert vm.topic == topic - assert vm.active_broker_profile.id == broker_id - assert window.findChild(QToolButton, "snapshotAdvancedButton") is None - assert window.findChild(QToolButton, "topicMetadataAdvancedButton") is None - window.close() - application.processEvents() - - -@pytest.mark.parametrize("editor_name", ["snapshot", "subscription", "topic", "broker"]) -def test_mode_switch_refuses_to_hide_dirty_forms(editor_name) -> None: - application = QApplication.instance() or QApplication([]) - repository = FakeGuiRepository() - vm = MainViewModel(runtime_for(repository), repository.state.topic) - window = MainWindow(vm) - window._advanced_mode_action.setChecked(True) - if editor_name == "snapshot": - pane = window._snapshot_panel - field = pane._topic_filter - window._show_snapshot() - elif editor_name == "subscription": - pane = window._subscription_settings - field = pane._filter_edit - window._show_topic_details() - else: - pane = window._topic_expectations if editor_name == "topic" else window._health_inspector._broker_expectations - pane.start_new() - field = pane._name - before = field.text() - field.setText("unfinished draft") - assert pane.has_unsaved_edits - destination = window._inspector_stack.currentWidget() - with patch.object(QMessageBox, "information") as message: - window._advanced_mode_action.setChecked(False) - message.assert_called_once() - assert "No edits were changed" in message.call_args.args[2] - assert window._advanced_mode_action.isChecked() - assert window._advanced_mode - assert window._inspector_stack.currentWidget() is destination - assert field.text() == "unfinished draft" - if editor_name == "snapshot": - window._render_tree() - else: - pane.render(vm.topic, vm.selected_subscription) if editor_name == "subscription" else pane.render() - assert field.text() == "unfinished draft" - field.setText(before) - assert not pane.has_unsaved_edits - window._advanced_mode_action.setChecked(False) - assert not window._advanced_mode - window.close() - application.processEvents() - - -def test_mode_switch_preserves_basic_drafts_and_existing_configuration() -> None: - application = QApplication.instance() or QApplication([]) - repository = FakeGuiRepository() - repository.subscriptions = (Subscription(repository.state.topic, qos=2, retain_as_published=True, retain_handling=2),) - vm = MainViewModel(runtime_for(repository), repository.state.topic) - window = MainWindow(vm) - vm.apply_snapshot_query(SnapshotQuery("home/#", 12, 9, 256)) - query = vm.snapshot_query - subscription = vm.selected_subscription - window._show_topic_details() - draft = window.findChild(QPlainTextEdit, "publishPayload") - draft.setPlainText("unsent message") - window._event_history.topic_filter.setText("home/+/temperature") - window._event_history.limit.setValue(17) - window._topic_expectations.start_new() - window._topic_expectations._name.setText("Unfinished expectation") - dialog = BrokerSettingsDialog(vm, window) - dialog._name_edit.setText("Unfinished profile") - dialog.show() - # Entering Advanced mode exposes controls without replacing drafts. - window._advanced_mode_action.setChecked(True) - assert window._topic_expectations._name.text() == "Unfinished expectation" - window._topic_expectations._name.clear() - with patch.object(window, "_run_async") as operations, patch.object(vm, "save_expectation") as save: - window._advanced_mode_action.setChecked(False) - operations.assert_not_called() - save.assert_not_called() - assert not window._advanced_mode - assert draft.toPlainText() == "unsent message" - assert dialog._name_edit.text() == "Unfinished profile" - assert not dialog._host_edit.isHidden() - assert not dialog._use_tls_checkbox.isHidden() - assert window._event_history.topic_filter.text() == "home/+/temperature" - assert window._event_history.limit.value() == 17 - assert vm.snapshot_query == query - assert vm.selected_subscription == subscription - assert window._subscription_settings._retain_handling.currentIndex() == 2 - assert window._subscription_settings._retain_as_published.isChecked() - assert "Do not send retained messages" in window._subscription_settings._options_summary.text() - assert window._observer_tree._scope.isHidden() - assert window._observer_tree._scope.text() == "" - dialog.close() - window.close() - application.processEvents() - - -def test_open_specialist_windows_require_explicit_resolution_before_switching() -> None: - application = QApplication.instance() or QApplication([]) - window = MainWindow(MainViewModel(runtime_for(FakeGuiRepository()))) - window._advanced_mode_action.setChecked(True) - # A nonmodal specialist window can contain edits not yet applied to its model. - dialog = StoredObservationsDialog(window._view_model, window) - window._stored_observations_dialog = dialog - dialog.show() - with patch.object(QMessageBox, "information") as message: - window._advanced_mode_action.setChecked(False) - assert window._advanced_mode_action.isChecked() - assert dialog.isVisible() - assert "Close Stored observations" in message.call_args.args[2] - dialog.close() - window._advanced_mode_action.setChecked(False) - assert not window._advanced_mode_action.isChecked() - window.close() - application.processEvents() - - -def test_global_mode_switch_has_no_operational_side_effects() -> None: - from contextlib import ExitStack - - application = QApplication.instance() or QApplication([]) - vm = MainViewModel(runtime_for(FakeGuiRepository())) - window = MainWindow(vm) - methods = ( - "connect_to_broker", "disconnect_from_broker", "reconnect_and_observe", - "publish_message", "set_history_recording", "save_history_settings", - "save_expectation", "delete_expectation", "update_subscription", - "apply_snapshot_query", "reset_snapshot_query", - "confirm_retention_policy", "confirm_cache_deletion", "delete_health_history", - ) - with ExitStack() as stack: - operations = [stack.enter_context(patch.object(vm, name)) for name in methods] - schedule = stack.enter_context(patch.object(window, "_run_async")) - for mode in (True, False, True, False): - window._advanced_mode_action.setChecked(mode) - for operation in (*operations, schedule): - operation.assert_not_called() - window.close() - application.processEvents() - - -def test_simplified_expectation_save_preserves_hidden_configuration() -> None: - from topicgate.core.models.health import TopicTarget - - application = QApplication.instance() or QApplication([]) - repository = FakeGuiRepository() - vm = MainViewModel(runtime_for(repository), repository.state.topic) - window = MainWindow(vm) - rule = HealthExpectation( - uuid4(), 3, True, HealthSeverity.WARNING, - TopicTarget(vm.active_broker_profile.id, repository.state.topic), - EqualCondition(b"21.5"), frozenset(), "Temperature", - description="Existing description", - ) - editor = window._topic_expectations - editor._load(rule) - assert editor._log_action.isHidden() - assert not editor._log_action.isChecked() - assert not editor._store_action.isChecked() - assert "no actions" in editor._options_summary.text() - assert not editor._options_summary.isHidden() - editor._name.setText("Renamed temperature") - with patch.object(vm, "save_expectation") as save: - editor._save() - values = save.call_args.kwargs - assert values["description"] == rule.description - assert values["log_action"] is False - assert values["store_failure"] is False - assert values["expectation_id"] == rule.expectation_id - assert values["name"] == "Renamed temperature" - window.close() - application.processEvents() - - -@pytest.mark.parametrize("size", [12, 16, 24, 32, 48]) -def test_shared_control_icons_render_at_desktop_sizes(size: int) -> None: - from topicgate.gui.icons import IconName, icon - - app = QApplication.instance() or QApplication([]) - for name in IconName: - for mode in (icon(name).Mode.Normal, icon(name).Mode.Disabled): - pixmap = icon(name).pixmap(size, size, mode) - assert not pixmap.isNull(), name - image = pixmap.toImage() - assert any( - image.pixelColor(x, y).alpha() > 0 - for x in range(image.width()) for y in range(image.height()) - ), name diff --git a/tests/test_main_view_model.py b/tests/test_main_view_model.py index 437dd35..bbcea16 100644 --- a/tests/test_main_view_model.py +++ b/tests/test_main_view_model.py @@ -1249,7 +1249,7 @@ def test_exact_topic_detail_survives_active_snapshot_topic_filter() -> None: assert view_model.topic_paths == [topic] assert view_model.topic_detail.decoded_payload == "online" assert view_model.topic_detail.snapshot_scope_note == ( - "Current value available, but excluded from snapshot values and counts because " + "Current value available, but omitted from the observer tree because " "the active topic filter 'other/#' excludes it." ) @@ -1269,63 +1269,11 @@ def test_exact_topic_detail_survives_snapshot_result_limit() -> None: ] assert view_model.topic_detail.decoded_payload == "online" assert view_model.topic_detail.snapshot_scope_note == ( - "Current value available, but excluded from snapshot values and counts because " + "Current value available, but omitted from the observer tree because " "it is beyond the active result limit." ) -def test_wildcard_tree_survives_snapshot_result_limit() -> None: - repository = FakeObserverRepository() - wildcard = Subscription("SmartHome/Huehnerstall/#") - exact = Subscription("SmartHome/Huehnerstall/door/battery") - repository.subscriptions = (wildcard, exact) - repository.publish( - MqttMessage("SmartHome/Huehnerstall/door/battery", b"98", 0, False) - ) - repository.publish( - MqttMessage("SmartHome/Huehnerstall/door/status", b"open", 0, False) - ) - view_model = MainViewModel(runtime_for(repository), wildcard.topic_filter) - - view_model.apply_snapshot_query(SnapshotQuery(result_limit=1)) - - assert [item.topic for item in view_model.broker_snapshot.topics] == [ - exact.topic_filter - ] - assert view_model.topic_paths == [ - wildcard.topic_filter, - exact.topic_filter, - "SmartHome/Huehnerstall/door/status", - ] - summary = view_model.selected_wildcard_filter_summary - assert summary is not None - assert summary.matching_topic_count == 2 - assert [item.topic for item in summary.topics] == [ - exact.topic_filter, - "SmartHome/Huehnerstall/door/status", - ] - door = view_model.topic_tree[0].children[0].children[1] - status = next(node for node in door.children if node.label == "status") - assert [badge.label for badge in status.badges] == ["F1", "Live"] - - -def test_wildcard_tree_ignores_active_snapshot_topic_filter() -> None: - repository = FakeObserverRepository() - wildcard = Subscription("SmartHome/Huehnerstall/#") - topic = "SmartHome/Huehnerstall/door/status" - repository.subscriptions = (wildcard,) - repository.publish(MqttMessage(topic, b"open", 0, False)) - view_model = MainViewModel(runtime_for(repository), wildcard.topic_filter) - - view_model.apply_snapshot_query(SnapshotQuery(topic_filter="home/#")) - - assert view_model.broker_snapshot.topics == () - assert view_model.topic_paths == [wildcard.topic_filter, topic] - summary = view_model.selected_wildcard_filter_summary - assert summary is not None - assert [item.topic for item in summary.topics] == [topic] - - def test_unobserved_exact_subscription_still_reports_waiting() -> None: repository = FakeObserverRepository() topic = "devices/kitchen/status" diff --git a/tests/test_paths.py b/tests/test_paths.py index 71f5cdd..4ab5ba9 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -1,23 +1,10 @@ from pathlib import Path -import os - -import pytest - -os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") - from PySide6.QtGui import QIcon -from PySide6.QtWidgets import QApplication from topicgate.paths import asset_path, prepare_database_path, sqlite_url -@pytest.fixture(scope="module", autouse=True) -def qt_application(): - app = QApplication.instance() or QApplication([]) - yield app - - def test_fresh_installation_uses_new_database_path(tmp_path: Path) -> None: target = prepare_database_path(tmp_path / "data") @@ -31,47 +18,3 @@ def test_application_icon_assets_are_available_from_the_package() -> None: assert Path(icon_path).is_file() assert Path(asset_path("icon.svg")).is_file() assert not QIcon(icon_path).isNull() - - -def test_control_icon_catalog_is_complete_and_rejects_strings() -> None: - import pytest - from topicgate.gui.icons import IconName, icon - - assert {name.value for name in IconName} == { - path.stem for path in Path(asset_path("icons")).glob("*.svg") - } - for name in IconName: - assert not icon(name).isNull() - with pytest.raises(TypeError, match="IconName"): - icon("edit") - for legacy in ("edit.svg", "delete.svg"): - assert not Path(asset_path(legacy)).exists() - - -def test_control_icon_missing_asset_fails_explicitly(monkeypatch, tmp_path) -> None: - import pytest - from topicgate.gui import icons - - monkeypatch.setattr(icons, "asset_path", lambda name: str(tmp_path / name)) - with pytest.raises(FileNotFoundError): - icons.icon(icons.IconName.EDIT) - - -def test_gui_control_icons_use_the_shared_catalog() -> None: - import ast - import topicgate.gui.icons - - root = Path(topicgate.gui.icons.__file__).parent - for path in root.rglob("*.py"): - if path.name == "icons.py": - continue - tree = ast.parse(path.read_text(encoding="utf-8")) - for node in ast.walk(tree): - if not isinstance(node, ast.Call): - continue - if isinstance(node.func, ast.Attribute): - assert node.func.attr not in {"standardIcon", "fromTheme"}, path - if isinstance(node.func, ast.Name) and node.func.id == "QIcon": - assert path.name in {"app.py", "main_window.py", "connection_controls.py"}, path - if path.name != "connection_controls.py": - assert ast.unparse(node.args[0]) == "asset_path('icon.png')", path diff --git a/tests/test_snapshot_presentation.py b/tests/test_snapshot_presentation.py index 4e387b9..7ab96cc 100644 --- a/tests/test_snapshot_presentation.py +++ b/tests/test_snapshot_presentation.py @@ -116,13 +116,13 @@ def test_topic_omission_notice_distinguishes_filter_and_result_limit() -> None: assert topic_omission_notice( SnapshotQuery(topic_filter="other/#"), snapshot, state ) == ( - "Current value available, but excluded from snapshot values and counts because " + "Current value available, but omitted from the observer tree because " "the active topic filter 'other/#' excludes it." ) assert topic_omission_notice( SnapshotQuery(result_limit=1), snapshot, state ) == ( - "Current value available, but excluded from snapshot values and counts because " + "Current value available, but omitted from the observer tree because " "it is beyond the active result limit." )