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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.7.5
0.7.6
2 changes: 1 addition & 1 deletion bin/pdrive-state
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 46 additions & 16 deletions bin/pdrive-ui
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 []
Expand All @@ -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

Expand All @@ -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"})
Expand Down Expand Up @@ -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(
Expand All @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions docs/EVERYDAY_USE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 9 additions & 7 deletions docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,19 +253,21 @@ 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
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
Expand Down Expand Up @@ -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. |
Expand Down
7 changes: 6 additions & 1 deletion tests/test-ui-preferences.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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]},
Expand Down Expand Up @@ -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()
Expand Down
16 changes: 16 additions & 0 deletions tests/test-ui-widgets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down