diff --git a/README.md b/README.md index 0e6245a..b9b4f02 100644 --- a/README.md +++ b/README.md @@ -69,12 +69,14 @@ 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, 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). +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). - **Live** values arrived in the current observation session. - **Cached** or **stored** values came from local persistence. @@ -84,6 +86,12 @@ 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. @@ -92,7 +100,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 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. +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).
See the broker expectation editor diff --git a/docs/DESKTOP_UX.md b/docs/DESKTOP_UX.md new file mode 100644 index 0000000..0382b78 --- /dev/null +++ b/docs/DESKTOP_UX.md @@ -0,0 +1,283 @@ +# 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 2fa523e..8fba237 100644 --- a/docs/OBSERVATION_HISTORY.md +++ b/docs/OBSERVATION_HISTORY.md @@ -29,12 +29,24 @@ before broker deletion; a drain timeout aborts deletion. ## Opt-in and independent retention -Open **Stored observations → History settings**, select a broker, check -**Record new events for this broker (opt-in)** and apply. The shared history +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 limits start at seven days, 100,000 events per broker, no per-topic cap, and -256 MiB of stored payloads globally. Age and per-topic caps can be left blank. +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. 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, @@ -74,12 +86,17 @@ coverage. Settings summaries and eviction generations apply globally where label ## Desktop pages -**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. +**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. 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 deleted file mode 100644 index 1a52f65..0000000 Binary files a/docs/images/desktop-app.png and /dev/null differ diff --git a/docs/images/desktop-first-run-checklist.png b/docs/images/desktop-first-run-checklist.png deleted file mode 100644 index cd15306..0000000 Binary files a/docs/images/desktop-first-run-checklist.png and /dev/null differ diff --git a/docs/images/observation-history-after.png b/docs/images/observation-history-after.png deleted file mode 100644 index 205439c..0000000 Binary files a/docs/images/observation-history-after.png and /dev/null differ diff --git a/docs/images/observation-history-before.png b/docs/images/observation-history-before.png deleted file mode 100644 index 9cad5fd..0000000 Binary files a/docs/images/observation-history-before.png and /dev/null differ diff --git a/docs/images/observation-history-settings.png b/docs/images/observation-history-settings.png deleted file mode 100644 index 93117bc..0000000 Binary files a/docs/images/observation-history-settings.png and /dev/null differ diff --git a/docs/images/plugin_in_codex.png b/docs/images/plugin_in_codex.png deleted file mode 100644 index 97ed8f9..0000000 Binary files a/docs/images/plugin_in_codex.png and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml index 9ab606e..75fc4b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ 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 deleted file mode 100644 index 1ba8d04..0000000 --- a/src/topicgate/assets/delete.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/topicgate/assets/edit.svg b/src/topicgate/assets/edit.svg deleted file mode 100644 index 2db66d4..0000000 --- a/src/topicgate/assets/edit.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/topicgate/assets/icons/broker.svg b/src/topicgate/assets/icons/broker.svg new file mode 100644 index 0000000..7ba7a06 --- /dev/null +++ b/src/topicgate/assets/icons/broker.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/src/topicgate/assets/icons/close.svg b/src/topicgate/assets/icons/close.svg new file mode 100644 index 0000000..a60b987 --- /dev/null +++ b/src/topicgate/assets/icons/close.svg @@ -0,0 +1,14 @@ + + + + diff --git a/src/topicgate/assets/icons/create.svg b/src/topicgate/assets/icons/create.svg new file mode 100644 index 0000000..8d8336f --- /dev/null +++ b/src/topicgate/assets/icons/create.svg @@ -0,0 +1,14 @@ + + + + diff --git a/src/topicgate/assets/icons/delete.svg b/src/topicgate/assets/icons/delete.svg new file mode 100644 index 0000000..cf36563 --- /dev/null +++ b/src/topicgate/assets/icons/delete.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/src/topicgate/assets/icons/edit.svg b/src/topicgate/assets/icons/edit.svg new file mode 100644 index 0000000..e06a749 --- /dev/null +++ b/src/topicgate/assets/icons/edit.svg @@ -0,0 +1,14 @@ + + + + diff --git a/src/topicgate/assets/icons/help.svg b/src/topicgate/assets/icons/help.svg new file mode 100644 index 0000000..b30bffe --- /dev/null +++ b/src/topicgate/assets/icons/help.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/src/topicgate/assets/icons/observer-tree.svg b/src/topicgate/assets/icons/observer-tree.svg new file mode 100644 index 0000000..9b6bb85 --- /dev/null +++ b/src/topicgate/assets/icons/observer-tree.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/src/topicgate/assets/icons/settings.svg b/src/topicgate/assets/icons/settings.svg new file mode 100644 index 0000000..78c4a56 --- /dev/null +++ b/src/topicgate/assets/icons/settings.svg @@ -0,0 +1,14 @@ + + + + diff --git a/src/topicgate/gui/components/about_dialog.py b/src/topicgate/gui/components/about_dialog.py index acf1742..4dea02a 100644 --- a/src/topicgate/gui/components/about_dialog.py +++ b/src/topicgate/gui/components/about_dialog.py @@ -1,5 +1,7 @@ from importlib.metadata import PackageNotFoundError, version +from topicgate.gui.icons import IconName, icon + from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QDialog, @@ -89,6 +91,7 @@ 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 d45abfb..928867b 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 delete_icon, edit_icon +from topicgate.gui.icons import IconName, 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(edit_icon()) + edit_button.setIcon(icon(IconName.EDIT)) 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(delete_icon()) + delete_button.setIcon(icon(IconName.DELETE)) 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("+ Add Broker", self._popup_menu) + add_action = QAction(icon(IconName.CREATE), "Add Broker", self._popup_menu) add_action.setObjectName("addBrokerProfilePaneAction") add_action.setEnabled(self._management_enabled) add_action.triggered.connect(self._request_add) @@ -199,7 +199,6 @@ class BrokerConnectionPane(WorkspacePane): connect_requested = Signal() reconnect_requested = Signal() disconnect_requested = Signal() - inspect_snapshot_requested = Signal() health_requested = Signal() _STATUS_LABELS = { @@ -211,12 +210,16 @@ 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") @@ -227,19 +230,37 @@ def __init__(self) -> None: QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Preferred, ) - self.header_layout.setStretch(0, 0) + self.header_layout.setStretch(1, 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.setSpacing(8) + broker_grid.setHorizontalSpacing(8) + broker_grid.setVerticalSpacing(6) 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(160) + self._profile_selector.setMinimumWidth(80) self._profile_selector.currentIndexChanged.connect( self._select_profile ) @@ -253,29 +274,30 @@ 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("Health: Not evaluated") + self._health_button = QPushButton() + self._health_button.setText("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._lifecycle_button, 0, 1) - broker_grid.addWidget(self._health_button, 1, 0) - broker_grid.addWidget(self._inspect_snapshot_button, 1, 1) + 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) self.content_layout.addLayout(broker_grid) self.setMaximumHeight(152) @@ -301,9 +323,16 @@ 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 fadb9ab..c9e026c 100644 --- a/src/topicgate/gui/components/connection_controls.py +++ b/src/topicgate/gui/components/connection_controls.py @@ -1,6 +1,8 @@ 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 @@ -211,6 +213,7 @@ 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 e58ba68..a89c30c 100644 --- a/src/topicgate/gui/components/diagnostic_profile_editor.py +++ b/src/topicgate/gui/components/diagnostic_profile_editor.py @@ -3,6 +3,8 @@ 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, @@ -30,8 +32,10 @@ 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 521d5a3..a828792 100644 --- a/src/topicgate/gui/components/event_history_widget.py +++ b/src/topicgate/gui/components/event_history_widget.py @@ -7,27 +7,35 @@ from PySide6.QtWidgets import ( QCheckBox, QComboBox, QDateTimeEdit, QFormLayout, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPlainTextEdit, QPushButton, QSpinBox, QTableWidget, - QTableWidgetItem, QVBoxLayout, QWidget, + QTableWidgetItem, QWidget, ) +from topicgate.gui.components.workspace_pane import WorkspacePane + if TYPE_CHECKING: from topicgate.gui.main_view_model import MainViewModel -class EventHistoryWidget(QWidget): +class EventHistoryWidget(WorkspacePane): 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__(parent) + super().__init__("Message history", minimum_hint_width=320) + if parent is not None: + self.setParent(parent) self._view_model = view_model - layout = QVBoxLayout(self) - description = QLabel( - "Individual TopicGate-observed receipts, oldest first. This is not authoritative " - "broker history. Enable recording per broker in History settings." + 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." ) - description.setWordWrap(True) - layout.addWidget(description) - form = QFormLayout() + form = self._form = QFormLayout() + form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) self.broker = QComboBox() self.broker.setObjectName("eventHistoryBroker") self.broker.setAccessibleName("Event history broker") @@ -36,7 +44,6 @@ 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") @@ -46,20 +53,44 @@ 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.refresh = QPushButton("Refresh snapshot") + self.search.setProperty("primary", True) + self.search.setToolTip("Start a new search, including newly saved messages.") 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.refresh, self.next_page): + for button in (self.search, self.next_page): actions.addWidget(button) + actions.addStretch() layout.addLayout(actions) - self.status = QLabel("Search to read committed event history.") + self.status = QLabel("Search saved message history.") self.status.setObjectName("eventHistoryStatus") self.status.setTextFormat(Qt.TextFormat.PlainText) self.status.setWordWrap(True) @@ -69,6 +100,7 @@ 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") @@ -92,10 +124,71 @@ 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"Use {title.lower()} bound") + enabled = QCheckBox(f"Received {title.lower()}") editor = QDateTimeEdit(QDateTime.currentDateTimeUtc()) editor.setDisplayFormat("yyyy-MM-dd HH:mm:ss 'UTC'") editor.setCalendarPopup(True) @@ -124,7 +217,6 @@ 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() @@ -139,23 +231,18 @@ def render(self) -> None: if column == 0: item.setData(Qt.ItemDataRole.UserRole, event) self.results.setItem(index, column, item) - 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._render_page_status() self.limitations.setPlainText("\n".join( item.replace("follow next_cursor", "use Next page") for item in page.limitations )) else: - self.status.setText("Search to read committed event history.") + self.status.setText("Search saved message history.") self.limitations.clear() if vm.event_history_busy: - self.status.setText("Loading committed event history…") + self.status.setText("Loading saved 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) @@ -163,8 +250,33 @@ 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}" - self.payload.setPlainText( + details = ( 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{payload}" + 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'}." ) diff --git a/src/topicgate/gui/components/expectation_editor.py b/src/topicgate/gui/components/expectation_editor.py index 50f1fdf..1f8f8a0 100644 --- a/src/topicgate/gui/components/expectation_editor.py +++ b/src/topicgate/gui/components/expectation_editor.py @@ -1,6 +1,8 @@ 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, @@ -49,6 +51,9 @@ 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" @@ -60,6 +65,7 @@ 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") @@ -87,11 +93,17 @@ def __init__( QHeaderView.ResizeMode.ResizeToContents, ) self._table.cellClicked.connect(self._select_row) - layout.addWidget(self._table, 1) + self._table.setMaximumHeight(150) + if target_kind == "topic": + self._table.setColumnHidden(1, True) + self._table.setColumnHidden(2, True) + layout.addWidget(self._table) 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") @@ -177,14 +189,21 @@ 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) @@ -195,16 +214,61 @@ 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"Expectations for {topic}" + f"{self._view_model.active_broker_profile.name} · Topic: {topic}" if available else "Select an exact topic to configure expectations." ) @@ -258,6 +322,7 @@ 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 @@ -278,6 +343,10 @@ 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): @@ -302,6 +371,7 @@ 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.""" @@ -336,9 +406,12 @@ 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 a206137..6fbc3db 100644 --- a/src/topicgate/gui/components/health_inspector.py +++ b/src/topicgate/gui/components/health_inspector.py @@ -1,5 +1,7 @@ from datetime import timezone +from topicgate.gui.icons import IconName, icon + from PySide6.QtCore import QDateTime, Qt, Signal from PySide6.QtWidgets import ( QCheckBox, @@ -12,6 +14,7 @@ QMessageBox, QPlainTextEdit, QPushButton, + QScrollArea, QTableWidget, QTableWidgetItem, QTabWidget, @@ -35,6 +38,7 @@ 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 @@ -43,8 +47,12 @@ 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") - self._tabs.addTab(self._broker_expectations, "Expectations") - self._tabs.addTab(self._history_page(), "History") + 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._changes_page(), "Changes") self._tabs.currentChanged.connect(self._tab_changed) self.content_layout.addWidget(self._tabs, 1) @@ -53,6 +61,95 @@ 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) @@ -87,8 +184,10 @@ 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") @@ -119,10 +218,11 @@ 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("Apply") + self._query_button = QPushButton("Search") 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) @@ -226,6 +326,7 @@ 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( @@ -278,6 +379,8 @@ 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: @@ -322,7 +425,8 @@ def _render_delta(self, report) -> None: for row, event in enumerate(events): values = ( str(event.kind).replace("_", " ").title(), - event.rule_id, + 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.matched_topic or "broker", self._transition_label( event.previous_status, @@ -471,6 +575,9 @@ 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 5ebc1f7..7dcc54a 100644 --- a/src/topicgate/gui/components/history_settings_widget.py +++ b/src/topicgate/gui/components/history_settings_widget.py @@ -4,11 +4,16 @@ from PySide6.QtCore import Signal, Qt from PySide6.QtWidgets import ( - QCheckBox, QComboBox, QFormLayout, QLabel, QLineEdit, QPushButton, + QCheckBox, QComboBox, QFormLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout, 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 @@ -46,27 +51,56 @@ 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 (seconds; blank = unlimited)"), + ("max_age_seconds", "Maximum age"), ("max_events_per_broker", "Maximum events per broker"), - ("max_events_per_topic", "Maximum events per topic (blank = unlimited)"), - ("max_payload_bytes", "Global history payload budget (bytes)"), + ("max_events_per_topic", "Maximum events per topic"), + ("max_payload_bytes", "Stored payload across all brokers"), ("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 - limits.addRow(label, 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) 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) @@ -78,39 +112,54 @@ 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 not value and name in ("max_age_seconds", "max_events_per_topic"): + if name in self.unlimited and self.unlimited[name].isChecked(): 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: - self.enabled.setChecked(status.enabled) + 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 and usage is not None: self.summary.setText( f"Broker history: {usage.event_count:,} events; {usage.payload_bytes:,} payload bytes. " @@ -121,6 +170,8 @@ 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) @@ -135,12 +186,24 @@ def _validate(self) -> None: self.save.setEnabled(False) else: self.error.clear() - self.save.setEnabled(self._loaded) + 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) 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 329b62f..be4ae12 100644 --- a/src/topicgate/gui/components/mcp_setup_dialog.py +++ b/src/topicgate/gui/components/mcp_setup_dialog.py @@ -1,3 +1,5 @@ +from topicgate.gui.icons import IconName, icon + from PySide6.QtCore import Signal from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import ( @@ -136,6 +138,7 @@ 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 4fc36aa..e333767 100644 --- a/src/topicgate/gui/components/observer_tree.py +++ b/src/topicgate/gui/components/observer_tree.py @@ -2,7 +2,6 @@ from PySide6.QtGui import QStandardItem, QStandardItemModel from PySide6.QtWidgets import ( QHBoxLayout, - QFrame, QHeaderView, QLabel, QLineEdit, @@ -12,8 +11,10 @@ ) 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 delete_icon +from topicgate.gui.icons import IconName, icon from topicgate.presentation.topic_presentation import TopicTreeNode TOPIC_ROLE = Qt.ItemDataRole.UserRole + 1 @@ -25,28 +26,43 @@ 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("Search topics...") + self._search_edit.setPlaceholderText("Filter displayed 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("+ Filter") + add_button.setText("Add subscription") + add_button.setIcon(icon(IconName.CREATE)) + add_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) 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"]) @@ -82,24 +98,8 @@ 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,48 +133,27 @@ def render( finally: self._rendering = False - def render_empty_state( - self, - connection_status: str, - subscriptions: tuple[Subscription, ...], - query_is_filtered: bool, - has_cached_values: bool, - has_topics: bool, + def render_scope( + self, query: SnapshotQuery, snapshot: BrokerSnapshot, subscription_count: int, ) -> None: - """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) + 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) def render_tree( self, @@ -195,6 +174,13 @@ 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: @@ -221,11 +207,6 @@ 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] = [] @@ -259,7 +240,7 @@ def _add_remove_button(self, subscription: Subscription) -> None: button.setObjectName("removeSubscriptionButton") button.setFixedSize(24, 18) button.setIconSize(QSize(12, 12)) - button.setIcon(delete_icon()) + button.setIcon(icon(IconName.DELETE)) 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 2692c5b..516f881 100644 --- a/src/topicgate/gui/components/onboarding_panel.py +++ b/src/topicgate/gui/components/onboarding_panel.py @@ -1,3 +1,5 @@ +from topicgate.gui.icons import IconName, icon + from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout @@ -32,6 +34,7 @@ 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 04a522e..4c9c8ed 100644 --- a/src/topicgate/gui/components/publish_pane.py +++ b/src/topicgate/gui/components/publish_pane.py @@ -38,8 +38,10 @@ def __init__(self) -> None: self._payload = QPlainTextEdit() self._payload.setObjectName("publishPayload") self._payload.setPlaceholderText("Message payload") - self._payload.setMaximumHeight(120) - layout.addWidget(self._payload) + self._payload.setAccessibleName("Message payload") + self._payload.setMinimumHeight(160) + self._payload.setTabChangesFocus(True) + layout.addWidget(self._payload, 1) 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 new file mode 100644 index 0000000..a5ca905 --- /dev/null +++ b/src/topicgate/gui/components/quantity_editor.py @@ -0,0 +1,23 @@ +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 72af042..0db6012 100644 --- a/src/topicgate/gui/components/snapshot_panel.py +++ b/src/topicgate/gui/components/snapshot_panel.py @@ -8,7 +8,6 @@ QLineEdit, QPushButton, QSpinBox, - QToolButton, QVBoxLayout, QWidget, ) @@ -31,21 +30,12 @@ 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 ( @@ -57,7 +47,6 @@ 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) @@ -190,18 +179,20 @@ def __init__(self) -> None: clear_button, observe_button, ) - self._rendered_query = SnapshotQuery() + self._rendered_query = None 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 is_advanced_visible(self) -> bool: - return self._advanced_button.isChecked() + def has_unsaved_edits(self) -> bool: + try: + return self.query != self._rendered_query + except ValueError: + return True @property def query(self) -> SnapshotQuery: @@ -219,10 +210,9 @@ 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( @@ -284,23 +274,11 @@ 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._advanced_button.setAccessibleDescription(f"{summary}.") + self.setAccessibleDescription(f"{summary}.") def _render_scope_summary(self) -> None: query = self._rendered_query @@ -328,6 +306,7 @@ 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 f943575..b3cac5f 100644 --- a/src/topicgate/gui/components/stored_observations_dialog.py +++ b/src/topicgate/gui/components/stored_observations_dialog.py @@ -1,6 +1,8 @@ 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, @@ -14,6 +16,7 @@ QLabel, QLineEdit, QProgressBar, + QScrollArea, QPlainTextEdit, QPushButton, QSpinBox, @@ -30,7 +33,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.event_history_widget import EventHistoryWidget +from topicgate.gui.components.quantity_editor import QuantityEditor from topicgate.presentation.retention_presentation import ( AgeUnit, ByteUnit, @@ -43,25 +46,6 @@ 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.""" @@ -85,22 +69,36 @@ 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(True) + self.tabs.tabBar().setExpanding(False) self.tabs.setAccessibleName("Stored observations settings pages") - 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._history_page(), "Latest stored values") 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") @@ -245,7 +243,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", ) @@ -293,7 +291,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) @@ -362,13 +360,17 @@ def _cache_page(self) -> QWidget: layout.addWidget(self.topics) actions = QHBoxLayout() for text, name, scope in ( - ("Delete selected topics…", "deleteSelectedTopicsButton", "selected_topics"), + ("Delete selected cached topics…", "deleteSelectedTopicsButton", "selected_topics"), ("Delete unsubscribed…", "deleteUnsubscribedButton", "unsubscribed"), ("Delete broker cache…", "deleteBrokerCacheButton", "broker"), - ("Delete all brokers…", "deleteAllCachesButton", "all_brokers"), + ("Delete cache for 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: ( @@ -667,7 +669,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: @@ -813,8 +815,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") @@ -871,14 +873,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 a7ac1f7..4c003ca 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 = QFormLayout() + form = self._form = QFormLayout() self._filter_edit = QLineEdit() self._qos_combo = self._compact_combo( [ @@ -58,13 +58,17 @@ def __init__(self) -> None: "2 - Do not send retained messages", ] ) - form.addRow("Filter", self._filter_edit) + form.addRow("MQTT subscription", 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") @@ -73,13 +77,22 @@ 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) @@ -89,6 +102,7 @@ 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: @@ -101,6 +115,30 @@ 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: @@ -117,6 +155,9 @@ 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 ce61691..fbf7d46 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 edit_icon +from topicgate.gui.icons import IconName, 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(edit_icon()) + self._edit_button.setIcon(icon(IconName.SETTINGS)) self._edit_button.setIconSize(QSize(14, 14)) self._edit_button.setToolButtonStyle( Qt.ToolButtonStyle.ToolButtonTextBesideIcon @@ -77,7 +77,6 @@ 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) @@ -138,7 +137,6 @@ 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() @@ -266,7 +264,8 @@ def set_settings_visible(self, visible: bool) -> None: def focus_payload(self) -> None: self._decoded_payload.setFocus(Qt.FocusReason.OtherFocusReason) - def _set_advanced_visible(self, visible: bool) -> None: + def set_advanced_mode(self, visible: bool) -> None: + self._metadata.set_advanced_mode(visible) self._advanced_visible = visible self._update_raw_visibility() @@ -286,6 +285,8 @@ 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 8ca7c61..971aa93 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, Signal -from PySide6.QtWidgets import QFormLayout, QLabel, QToolButton, QWidget +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QFormLayout, QLabel, QWidget from topicgate.presentation.topic_presentation import TopicDetail @@ -7,8 +7,6 @@ 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) @@ -62,14 +60,7 @@ def __init__(self) -> None: self.qos, self.dropped, ) - 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) + self.set_advanced_mode(False) def render(self, detail: TopicDetail) -> None: self.received.setText(detail.received_at) @@ -92,18 +83,9 @@ def render(self, detail: TopicDetail) -> None: self.messages.setText(str(detail.message_count)) self.dropped.setText(str(detail.dropped_message_count)) - def _set_advanced_visible(self, visible: bool) -> None: + def set_advanced_mode(self, advanced: bool) -> None: for widget in self._advanced_widgets: - 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) + self._form.setRowVisible(widget, advanced) @staticmethod def _label(name: str, text: str = "-") -> QLabel: diff --git a/src/topicgate/gui/icons.py b/src/topicgate/gui/icons.py index 0fdac2b..ebb8c1f 100644 --- a/src/topicgate/gui/icons.py +++ b/src/topicgate/gui/icons.py @@ -1,11 +1,29 @@ +"""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 -def edit_icon() -> QIcon: - return QIcon(asset_path("edit.svg")) +class IconName(StrEnum): + BROKER = "broker" + CLOSE = "close" + CREATE = "create" + DELETE = "delete" + EDIT = "edit" + HELP = "help" + OBSERVER_TREE = "observer-tree" + SETTINGS = "settings" -def delete_icon() -> QIcon: - return QIcon(asset_path("delete.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)) diff --git a/src/topicgate/gui/main_view_model.py b/src/topicgate/gui/main_view_model.py index 805597c..4ec2afd 100644 --- a/src/topicgate/gui/main_view_model.py +++ b/src/topicgate/gui/main_view_model.py @@ -20,7 +20,10 @@ FailureHistoryResult, FindingCheckpoint, ) -from topicgate.app.services.broker_snapshot_service import BrokerSnapshotService +from topicgate.app.services.broker_snapshot_service import ( + MAX_SNAPSHOT_RESULT_LIMIT, + BrokerSnapshotService, +) from topicgate.app.services.expectation_management_service import ( ExpectationManagementService, ) @@ -136,6 +139,7 @@ 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 @@ -153,6 +157,7 @@ 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( @@ -223,6 +228,11 @@ 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: @@ -780,17 +790,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._snapshot.topics) + observed_topics = tuple(item.topic for item in self._observer_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._snapshot.topics) + observed_topics = tuple(item.topic for item in self._observer_snapshot.topics) return build_topic_tree( self.topic_paths, self.subscriptions, observed_topics, - self._snapshot.topics, + self._observer_snapshot.topics, ) @property @@ -814,7 +824,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._snapshot.topics) + return wildcard_filter_summary(subscription, self._observer_snapshot.topics) async def start(self) -> None: """Load the current value and listen for messages and connection changes.""" @@ -875,6 +885,7 @@ 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 @@ -939,6 +950,7 @@ 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: @@ -961,6 +973,22 @@ 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: @@ -976,6 +1004,9 @@ 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, @@ -1322,6 +1353,7 @@ 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 @@ -1581,3 +1613,11 @@ 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 d19c435..79dcf0c 100644 --- a/src/topicgate/gui/main_window.py +++ b/src/topicgate/gui/main_window.py @@ -5,17 +5,22 @@ 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, ) @@ -29,6 +34,7 @@ ) 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 @@ -84,6 +90,7 @@ 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 @@ -92,6 +99,7 @@ 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) @@ -103,6 +111,7 @@ 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() @@ -150,9 +159,6 @@ 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 ) @@ -188,7 +194,11 @@ 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") - self._context_panel.content_layout.addWidget(self._settings_tabs) + 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._observer_workspace = QWidget() self._observer_workspace.setObjectName("observerWorkspace") @@ -202,6 +212,14 @@ 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() @@ -214,7 +232,15 @@ 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) @@ -231,6 +257,7 @@ 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) @@ -238,6 +265,42 @@ 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( @@ -245,6 +308,10 @@ 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) @@ -260,7 +327,7 @@ def _show_health(self) -> None: self._schedule_health_refresh() def _show_diagnostic_profiles(self) -> None: - if self._diagnostic_profile_editor is None: + if not self._advanced_mode or self._diagnostic_profile_editor is None: return broker_id = self._view_model.active_broker_profile.id if self._diagnostic_profile_editor_window is None: @@ -298,13 +365,23 @@ 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 @@ -313,6 +390,7 @@ 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" ) @@ -361,12 +439,10 @@ 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 filter", self) + self._add_filter_action = QAction("Add subscription", self) + self._add_filter_action.setIcon(icon(IconName.CREATE)) 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) @@ -396,7 +472,12 @@ 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 @@ -406,14 +487,17 @@ 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") @@ -446,7 +530,10 @@ 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) @@ -465,11 +552,83 @@ def _create_log_dock(self) -> None: self._log_dock, ) self._console_action.setChecked(self._log_dock.isVisible()) - self._console_action.toggled.connect(self._log_dock.setVisible) + self._console_action.toggled.connect(self._set_console_visible) 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() @@ -553,16 +712,6 @@ 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) @@ -611,21 +760,13 @@ def _render_tree(self) -> None: self._view_model.snapshot_health ) snapshot = self._view_model.broker_snapshot - 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._observer_tree.render_scope( + self._view_model.snapshot_query, snapshot, len(self._view_model.subscriptions) ) self._render_broker_connection() def _render_details(self) -> None: + self._render_destination() self._topic_details.render(self._view_model) self._topic_expectations.render() if ( @@ -653,6 +794,7 @@ 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() @@ -705,7 +847,6 @@ 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 ) @@ -740,16 +881,9 @@ 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) @@ -757,11 +891,6 @@ 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) @@ -818,7 +947,6 @@ 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) @@ -985,6 +1113,7 @@ 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: @@ -997,11 +1126,14 @@ def _apply_subscription( original_filter: str, subscription: Subscription, ) -> None: - self._run_async( - self._view_model.update_subscription( - original_filter, - subscription, - ) + 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}" ) def _show_add_filter_dialog(self) -> None: @@ -1067,7 +1199,6 @@ 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) ) @@ -1086,7 +1217,6 @@ 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, @@ -1118,7 +1248,6 @@ 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: @@ -1126,7 +1255,6 @@ 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: @@ -1243,16 +1371,13 @@ def _restore_state(self) -> None: if selected_topic: self._view_model.select_topic(selected_topic) self._restore_snapshot_preferences() - if self._view_model.topic: - self._show_topic_details() - else: - self._show_snapshot() - log_visible = self._settings.value( + self._show_health() + self._log_visible_preference = self._settings.value( "workspace/logVisible", False, type=bool, ) - self._log_dock.setVisible(log_visible) + self._log_dock.setVisible(self._log_visible_preference and self._advanced_mode) def _restore_snapshot_preferences(self) -> None: topic_filter = str( @@ -1278,9 +1403,6 @@ 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 @@ -1310,11 +1432,7 @@ def closeEvent(self, event: QCloseEvent) -> None: ) self._settings.setValue( "workspace/logVisible", - self._log_dock.isVisible(), - ) - self._settings.setValue( - "workspace/snapshotExpanded", - self._snapshot_panel.is_advanced_visible, + self._log_visible_preference, ) self._save_snapshot_preferences() self._settings.sync() @@ -1322,4 +1440,9 @@ def closeEvent(self, event: QCloseEvent) -> None: def showEvent(self, event: QShowEvent) -> None: super().showEvent(event) - self._topic_details.focus_payload() + 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() diff --git a/src/topicgate/gui/theme.py b/src/topicgate/gui/theme.py index b5847df..05ce533 100644 --- a/src/topicgate/gui/theme.py +++ b/src/topicgate/gui/theme.py @@ -3,12 +3,13 @@ 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; } @@ -38,11 +39,15 @@ 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: #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#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#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 bb94edf..48318af 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 omitted from the observer tree because " + "Current value available, but excluded from snapshot values and counts because " f"{reason}." ) diff --git a/tests/test_event_history_gui.py b/tests/test_event_history_gui.py index f6f3a13..26a4519 100644 --- a/tests/test_event_history_gui.py +++ b/tests/test_event_history_gui.py @@ -2,13 +2,15 @@ from threading import Event from unittest.mock import Mock -from PySide6.QtWidgets import QApplication +from PySide6.QtWidgets import QApplication, QPushButton 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 @@ -26,18 +28,31 @@ 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 = dialog.event_history - assert dialog.tabs.tabText(0) == "Latest stored state" - assert dialog.tabs.tabText(1) == "Event history" + 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())] 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 "disabled" in widget.status.text() + assert "recording" not in widget.status.text() assert "dropped 2" in widget.status.text() assert "incomplete" in widget.limitations.toPlainText() widget.results.selectRow(0) @@ -95,3 +110,239 @@ 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 a94024a..165795d 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -29,6 +29,7 @@ QPushButton, QPlainTextEdit, QScrollArea, + QSizePolicy, QStackedWidget, QSplitter, QSpinBox, @@ -248,11 +249,34 @@ 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" @@ -279,7 +303,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 observation_tabs.tabBar().expanding() + assert not 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 @@ -310,6 +334,87 @@ 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() @@ -364,10 +469,9 @@ 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 not pane.is_advanced_visible - assert not advanced.isChecked() + assert advanced is None assert not controls.isHidden() - assert advanced_content.isHidden() + assert not advanced_content.isHidden() assert pane.findChild(QLabel, "snapshotSummaryConnection").text() == ( "Disconnected" ) @@ -376,8 +480,6 @@ 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", @@ -386,11 +488,7 @@ 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) @@ -436,9 +534,8 @@ 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 advanced_content.isHidden() + assert not advanced_content.isHidden() assert not controls.isHidden() pane.findChild(QPushButton, "clearSnapshotFiltersButton").click() @@ -693,11 +790,13 @@ 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() @@ -709,7 +808,14 @@ def test_settings_button_reveals_subscription_and_expectation_settings() -> None edit_button.click() assert not context.isHidden() - assert edit_button.text() == "Settings" + assert edit_button.text() == "Close settings" + assert len( + [ + button + for button in window.findChildren(QPushButton) + if button.text() == "Close settings" + ] + ) == 0 assert context.findChild(QWidget, "topicExpectationEditor") is not None assert context.findChild(QWidget, "topicPublishPane") is None assert context.findChild(QPushButton, "revertSubscriptionButton") is None @@ -734,6 +840,7 @@ 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() @@ -964,12 +1071,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._snapshot_panel.set_advanced_visible(True) + first._advanced_mode_action.setChecked(True) first.close() second = MainWindow(MainViewModel(runtime_for(FakeGuiRepository())), settings) assert second._view_model.snapshot_query == SnapshotQuery("devices/#", 12.0, 9, 256) - assert second._snapshot_panel.is_advanced_visible + assert second._advanced_mode_action.isChecked() second.show() application.processEvents() second._focus_topic_search_action.trigger() @@ -1299,6 +1406,7 @@ 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) @@ -1410,7 +1518,7 @@ def test_health_inspector_renders_latest_finding_delta() -> None: application.processEvents() -def test_inspector_starts_from_selection_and_owns_one_snapshot() -> None: +def test_inspector_defaults_to_health_and_preserves_restored_selection() -> None: application = QApplication.instance() or QApplication([]) repository = FakeGuiRepository() empty_settings = QSettings( @@ -1426,7 +1534,8 @@ def test_inspector_starts_from_selection_and_owns_one_snapshot() -> None: stack = snapshot_window.findChild(QStackedWidget, "inspectorStack") snapshots = snapshot_window.findChildren(SnapshotPanel) scroll = snapshot_window.findChild(QScrollArea, "snapshotPanelScrollArea") - assert stack.currentIndex() == 0 + assert stack.currentWidget() is snapshot_window._health_inspector + assert snapshot_window._destination_tabs.currentIndex() == 0 assert len(snapshots) == 1 assert snapshots[0] is snapshot_window._snapshot_panel assert snapshot_window._observer_tree.findChildren(SnapshotPanel) == [] @@ -1446,7 +1555,10 @@ def test_inspector_starts_from_selection_and_owns_one_snapshot() -> None: assert details_window.findChild( QStackedWidget, "inspectorStack", - ).currentWidget() is details_window._topic_details + ).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 snapshot_window.close() details_window.close() @@ -1472,7 +1584,7 @@ def test_default_window_uses_isolated_settings( application.processEvents() -def test_selected_subscription_shows_value_omitted_from_observer_tree() -> None: +def test_selected_subscription_shows_value_outside_snapshot_scope() -> None: application = QApplication.instance() or QApplication([]) repository = FakeGuiRepository() topic = repository.state.topic @@ -1480,6 +1592,7 @@ def test_selected_subscription_shows_value_omitted_from_observer_tree() -> 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") @@ -1489,7 +1602,7 @@ def test_selected_subscription_shows_value_omitted_from_observer_tree() -> None: node for node in view_model.topic_tree if node.path == "home" ).children[0].children[0] assert selected_node.path == topic - assert not selected_node.is_observed + assert selected_node.is_observed assert tree is not None assert decoded.toPlainText() == "21.5" assert notice.isVisibleTo(window) @@ -1504,6 +1617,8 @@ 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) @@ -1523,6 +1638,7 @@ 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 @@ -1544,13 +1660,7 @@ def test_inspector_navigation_preserves_snapshot_and_tree_editing_state() -> Non window.findChild(QToolButton, "topicEditButton").setChecked(True) assert not window._context_panel.isHidden() - inspect = window.findChild(QPushButton, "inspectSnapshotButton") - requests: list[bool] = [] - window._broker_connection.inspect_snapshot_requested.connect( - lambda: requests.append(True) - ) - inspect.click() - assert requests == [True] + window._destination_tabs.setCurrentIndex(2) assert stack.currentIndex() == 0 assert window._view_model.topic == repository.state.topic assert window._context_panel.isHidden() @@ -1579,6 +1689,8 @@ 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 @@ -1607,6 +1719,8 @@ 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() @@ -1625,23 +1739,7 @@ async def test_reconnect_preserves_the_current_inspector_view() -> None: application.processEvents() -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: +def test_light_theme_keeps_dialog_text_readable() -> None: application = QApplication.instance() or QApplication([]) apply_light_theme(application) @@ -1651,8 +1749,6 @@ def test_light_theme_keeps_dialog_and_empty_state_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 @@ -2009,6 +2105,7 @@ 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") @@ -2037,10 +2134,13 @@ 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 - ) == 0 - assert window._broker_connection.header_layout.indexOf(status) == 1 + ) == 1 + assert window._broker_connection.header_layout.indexOf(status) == 2 assert window.findChild(QLabel, "activeBrokerEndpoint") is None assert window.findChild(QToolButton, "brokerSettingsButton") is None assert window.findChild(QWidget, "applicationHeader") is None @@ -2081,7 +2181,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") - inspect_snapshot = window.findChild(QPushButton, "inspectSnapshotButton") + management = window.findChild(QToolButton, "manageBrokersButton") profile_menu = window.findChild(QMenu, "brokerProfileSelectorMenu") assert [selector.itemText(index) for index in range(selector.count())] == [ @@ -2092,22 +2192,36 @@ 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 inspect_snapshot.isEnabled() - assert inspect_snapshot.accessibleName() == "Inspect broker snapshot" + 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 window.resize(window.minimumSize()) window.show() application.processEvents() assert selector.isVisible() assert health.isVisible() - assert inspect_snapshot.isVisible() + assert management.isVisible() assert lifecycle.isVisible() assert lifecycle.text() == "Disconnect" - 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 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 [ action.defaultWidget() .findChild(QToolButton, "selectBrokerProfileButton") @@ -2127,7 +2241,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 @@ -2176,6 +2290,7 @@ 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"] @@ -2217,15 +2332,14 @@ def test_topic_metadata_hides_advanced_fields_until_requested() -> None: messages = pane.findChild(QLabel, "messageCountLabel") raw = pane.findChild(QPlainTextEdit, "rawPayload") - assert advanced.text() == "Advanced" + assert advanced is None assert source.isHidden() assert raw.isHidden() assert not state.isHidden() assert not messages.isHidden() - advanced.click() + pane.set_advanced_mode(True) - assert advanced.text() == "Hide advanced" assert not source.isHidden() assert not raw.isHidden() @@ -2283,7 +2397,7 @@ def test_topic_details_switches_between_payload_and_embedded_publish() -> None: assert pane.heading.toolTip() == repository.state.topic assert publish_hint.isHidden() - advanced.click() + pane.set_advanced_mode(True) assert not raw.isHidden() publish_payload.setPlainText("outgoing draft") modes.setCurrentIndex(1) @@ -2299,7 +2413,7 @@ def test_topic_details_switches_between_payload_and_embedded_publish() -> None: assert not decoded.isHidden() assert not raw.isHidden() - assert advanced.isChecked() + assert not raw.isHidden() assert pane.heading.toolTip() == repository.state.topic assert publish_payload.toPlainText() == "outgoing draft" pane.close() @@ -3044,7 +3158,7 @@ async def scenario() -> None: assert repository.broker_configurations == [local_profile.config] assert broker_repository.get_profile().id == local_profile.id - assert stack.currentIndex() == 0 + assert stack.currentWidget() is window._health_inspector 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() @@ -3062,6 +3176,7 @@ 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] @@ -3178,3 +3293,270 @@ 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 bbcea16..437dd35 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 omitted from the observer tree because " + "Current value available, but excluded from snapshot values and counts because " "the active topic filter 'other/#' excludes it." ) @@ -1269,11 +1269,63 @@ 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 omitted from the observer tree because " + "Current value available, but excluded from snapshot values and counts 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 4ab5ba9..71f5cdd 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -1,10 +1,23 @@ 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") @@ -18,3 +31,47 @@ 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 7ab96cc..4e387b9 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 omitted from the observer tree because " + "Current value available, but excluded from snapshot values and counts because " "the active topic filter 'other/#' excludes it." ) assert topic_omission_notice( SnapshotQuery(result_limit=1), snapshot, state ) == ( - "Current value available, but omitted from the observer tree because " + "Current value available, but excluded from snapshot values and counts because " "it is beyond the active result limit." )