diff --git a/VERSION b/VERSION index 8bd6ba8..c006218 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.5 +0.7.6 diff --git a/bin/pdrive-state b/bin/pdrive-state index fad33e1..14111dc 100755 --- a/bin/pdrive-state +++ b/bin/pdrive-state @@ -21,7 +21,7 @@ from typing import Any SCHEMA_VERSION = 1 -TOOL_VERSION = "0.7.5" +TOOL_VERSION = "0.7.6" RECENT_TRANSFER_WINDOW_SECONDS = 24 * 60 * 60 RECENT_TRANSFER_LIMIT = 24 MOUNT_LOG_TAIL_BYTES = 512 * 1024 diff --git a/bin/pdrive-ui b/bin/pdrive-ui index 63f36a3..8abd23f 100755 --- a/bin/pdrive-ui +++ b/bin/pdrive-ui @@ -44,7 +44,7 @@ except (ImportError, ValueError): APP_ID = "io.github.claudiuschuster.PDriveControl" -VERSION = "0.7.5" +VERSION = "0.7.6" REFRESH_INTERVAL_SECONDS = 2 REFRESH_INTERVAL_OPTIONS = (1, 2, 5, 10) GRAPH_WINDOW_SECONDS = 5 * 60 @@ -828,16 +828,35 @@ def load_preferences() -> dict[str, Any]: return preferences -def unreviewed_events(preferences: dict[str, Any], issues: dict[str, Any]) -> list[dict[str, Any]]: - """Return consolidated incidents observed after the review watermark.""" +def event_is_after_review( + preferences: dict[str, Any], + event: dict[str, Any], + timestamp_keys: tuple[str, ...], +) -> bool: + """Return whether the event timestamp is newer than the review watermark.""" reviewed_at = str(preferences.get("issues_reviewed_at") or "") if not reviewed_at: - return [] + return False try: reviewed_time = dt.datetime.fromisoformat(reviewed_at) except ValueError: reviewed_time = dt.datetime.min.replace(tzinfo=dt.timezone.utc) + event_timestamp = next((str(event.get(key) or "") for key in timestamp_keys if event.get(key)), "") + try: + event_time = dt.datetime.fromisoformat(event_timestamp) + if event_time.tzinfo is None: + event_time = event_time.replace(tzinfo=dt.timezone.utc) + if reviewed_time.tzinfo is None: + reviewed_time = reviewed_time.replace(tzinfo=dt.timezone.utc) + except ValueError: + return False + return event_time > reviewed_time + + +def unreviewed_events(preferences: dict[str, Any], issues: dict[str, Any]) -> list[dict[str, Any]]: + """Return consolidated incidents observed after the review watermark.""" + raw_events = issues.get("events", []) if not isinstance(raw_events, list): return [] @@ -847,16 +866,7 @@ def unreviewed_events(preferences: dict[str, Any], issues: dict[str, Any]) -> li continue if str(event.get("lifecycle") or "active") == "resolved": continue - try: - event_time = dt.datetime.fromisoformat(str(event.get("last_seen") or event.get("timestamp") or "")) - if event_time.tzinfo is None: - event_time = event_time.replace(tzinfo=dt.timezone.utc) - comparison_time = reviewed_time - if comparison_time.tzinfo is None: - comparison_time = comparison_time.replace(tzinfo=dt.timezone.utc) - except ValueError: - continue - if event_time > comparison_time: + if event_is_after_review(preferences, event, ("last_seen", "timestamp")): selected.append(event) return selected @@ -870,6 +880,25 @@ def resolved_events(issues: dict[str, Any], limit: int = 5) -> list[dict[str, An ][:limit] +def unreviewed_recovery_events( + preferences: dict[str, Any], + issues: dict[str, Any], + limit: int = 5, +) -> list[dict[str, Any]]: + """Return resolved incidents whose recovery has not been reviewed yet.""" + + raw_events = issues.get("events", []) + if not isinstance(raw_events, list): + return [] + return [ + event + for event in raw_events + if isinstance(event, dict) + and str(event.get("lifecycle") or "") == "resolved" + and event_is_after_review(preferences, event, ("resolved_at", "last_seen", "timestamp")) + ][:limit] + + def issues_since_review(preferences: dict[str, Any], issues: dict[str, Any]) -> tuple[int, int]: events = unreviewed_events(preferences, issues) errors = sum(1 for event in events if str(event.get("level") or "").lower() in {"error", "critical"}) @@ -5192,6 +5221,7 @@ class PDriveWindow(Gtk.ApplicationWindow): preferences = application.preferences if isinstance(application, PDriveApplication) else DEFAULT_PREFERENCES errors, notices = issues_since_review(preferences, issues) total = errors + notices + recoveries = unreviewed_recovery_events(preferences, issues) self.problem_card.update( str(total), translate_format( @@ -5200,7 +5230,7 @@ class PDriveWindow(Gtk.ApplicationWindow): notices=notices, ), ) - self.mark_issues_reviewed_button.set_sensitive(total > 0 and not self.demo) + self.mark_issues_reviewed_button.set_sensitive(bool(total or recoveries) and not self.demo) def issue_row(self, event: dict[str, Any]) -> Gtk.ListBoxRow: row = Gtk.ListBoxRow() @@ -5293,7 +5323,7 @@ class PDriveWindow(Gtk.ApplicationWindow): application = self.get_application() preferences = application.preferences if isinstance(application, PDriveApplication) else DEFAULT_PREFERENCES events, missing = unreviewed_issue_events(preferences, issues) - recoveries = resolved_events(issues) + recoveries = unreviewed_recovery_events(preferences, issues) errors, notices = issues_since_review(preferences, issues) signature = ( errors, diff --git a/docs/EVERYDAY_USE.md b/docs/EVERYDAY_USE.md index 3d446a6..82f5f16 100644 --- a/docs/EVERYDAY_USE.md +++ b/docs/EVERYDAY_USE.md @@ -96,8 +96,10 @@ the default Important policy. Select **Unreviewed issues** to inspect timestamps, severity, sanitized context, affected paths and suggested next steps. Opening the list does not acknowledge -anything; mark it reviewed only after reading the evidence. Automatically -recovered incidents remain visible as resolved context. +anything; mark it reviewed only after reading the evidence. The button is also +available when only informational recovery records are waiting. Marking them +reviewed removes them from this list while their history and log evidence remain +available. ## Before shutdown, update or uninstall diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 7b281f6..364dc09 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -253,10 +253,11 @@ file-operation incidents and does not remain unreviewed after current runtime checks prove authentication, mount and RC health. Routine bandwidth-control notices do not enter issue review. Automatically resolved incidents remain visible as a single informational -recovery record. A recovered root-level session refresh keeps its displayed time -anchored to the final related 401 log event instead of changing with each status -poll. An old message or a path merely disappearing from the queue is never enough -to declare success. +recovery record until they are marked reviewed. The review button remains +available when only these recovery records are waiting. A recovered root-level +session refresh keeps its displayed time anchored to the final related 401 log +event instead of changing with each status poll. An old message or a path merely +disappearing from the queue is never enough to declare success. Each retained incident shows its local timestamp, severity, category, affected path or component, sanitized rclone context and a suggested next step. Its @@ -264,8 +265,9 @@ occurrence label describes related records in the retained recent log window; the count may decrease as old records leave that bounded window and is not a lifetime total. API URLs, Proton share/link identifiers and credential-shaped values are redacted before entering the JSON snapshot. Only **Mark issues -reviewed** advances the watermark; it does not delete logs, watchdog history, -recovery records or current health warnings. +reviewed** advances the watermark and hides reviewed incidents and recovery +records from issue review; it does not delete logs, watchdog history, transfer +history or current health warnings. The Active, Queue and VFS-Cache overview cards provide keyboard and pointer shortcuts into the corresponding Transfers sections. The cache @@ -428,7 +430,7 @@ format, CLI equivalent and refusal conditions: | **Restart cooldown → Reset restart cooldown** | Clears only the current automatic-restart cooldown through `pdrive-watch --clear-cooldown`; the configured duration is unchanged. | | **Refresh metadata** | Checks uploads, queue and Dirty cache first, then requires terminal confirmation before a controlled restart. | | **Safely restart service** | Warns that an active upload would be interrupted, requires terminal confirmation and validates the new PID and mount. | -| **Mark issues reviewed** | Advances only the local issue watermark; it does not delete logs, history or unresolved health evidence. | +| **Mark issues reviewed** | Advances only the local issue watermark and clears reviewed issues or recovery information from issue review; it does not delete logs, history or unresolved health evidence. | | **Open Proton Drive web** | Opens the official web client for account-wide settings; it makes no local PDrive change. | | **Open PDrive folder** | Opens `/pdrive` in the file manager; reads and writes then follow normal mounted-filesystem semantics. | | Overview banner or conditional menu **Reauthorize Proton account …** | Appears only when PDrive reports `reauthorization-required`; runs one isolated same-account login and replaces the encrypted configuration only after Proton accepts it. | diff --git a/tests/test-ui-preferences.sh b/tests/test-ui-preferences.sh index 26be121..68cc0f1 100755 --- a/tests/test-ui-preferences.sh +++ b/tests/test-ui-preferences.sh @@ -164,7 +164,8 @@ issue_events = [ {"timestamp": "2026-08-24T13:00:00+02:00", "level": "error", "message": "new error"}, {"timestamp": "2026-08-24T13:15:00+02:00", "level": "notice", "message": "new notice"}, { - "timestamp": "2026-08-24T13:30:00+02:00", + "timestamp": "2026-08-24T11:45:00+02:00", + "resolved_at": "2026-08-24T13:30:00+02:00", "level": "notice", "lifecycle": "resolved", "message": "automatic recovery", @@ -180,6 +181,9 @@ selected, missing = module.unreviewed_issue_events( assert [event["message"] for event in selected] == ["new error", "new notice"] assert missing == 0 assert [event["message"] for event in module.resolved_events(issue_payload)] == ["automatic recovery"] +assert [event["message"] for event in module.unreviewed_recovery_events(reviewed, issue_payload)] == [ + "automatic recovery" +] selected, missing = module.unreviewed_issue_events( reviewed, {"available": True, "events": issue_events[3:4]}, @@ -368,6 +372,7 @@ reviewed_again = module.load_preferences() assert reviewed_again["issues_reviewed_errors"] == 2 assert reviewed_again["issues_reviewed_notices"] == 1 assert reviewed_again["issues_reviewed_at"] == "2026-08-24T13:30:00+02:00" +assert module.unreviewed_recovery_events(reviewed_again, issue_payload) == [] module.PDriveApplication.sync_autostart(False) assert not autostart.exists() diff --git a/tests/test-ui-widgets.sh b/tests/test-ui-widgets.sh index 063e4c3..38817b8 100755 --- a/tests/test-ui-widgets.sh +++ b/tests/test-ui-widgets.sh @@ -829,6 +829,22 @@ app.update_indicator(window.current_state, 0) assert app.status_icon is None or "0 B/s" in app.status_icon.get_tooltip_text() assert len(window.issue_list.get_children()) == 5 assert not window.mark_issues_reviewed_button.get_sensitive() +window.demo = False +app.preferences["issues_reviewed_at"] = ( + module.dt.datetime.now().astimezone() - module.dt.timedelta(minutes=5) +).isoformat(timespec="seconds") +window.update_issue_card(window.current_state["issues"]) +window.update_issues(window.current_state["issues"]) +assert window.mark_issues_reviewed_button.get_sensitive() +assert len(window.issue_list.get_children()) == 1 +assert "1 recent recovery record" in window.issue_summary.get_text() +app.preferences["issues_reviewed_at"] = window.current_state["generated_at"] +window.update_issue_card(window.current_state["issues"]) +window.update_issues(window.current_state["issues"]) +assert not window.mark_issues_reviewed_button.get_sensitive() +assert len(window.issue_list.get_children()) == 1 +assert window.issue_summary.get_text() == "There are no new rclone errors or notices since your last review." +window.demo = True assert isinstance(window.problem_card, module.Gtk.EventBox) assert window.problem_card.get_above_child() problem_frame_context = window.problem_card.frame.get_style_context()