diff --git a/AGENTS.md b/AGENTS.md index 0d747ff..1de7602 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,6 +206,8 @@ project-specific; CI remains the authority for mechanical formatting rules. - Keep commits focused and use short imperative English subjects. Preserve unrelated working-tree changes and never commit private operating notes from outside this repository. +- The repository accepts squash merges only. Keep pull-request commits focused, + then merge through GitHub's squash path rather than creating a merge commit. - Update this file when verified packaging, migration, rollback, or store-review behavior establishes a new repository-wide invariant. Do not encode an untested publishing assumption as policy. diff --git a/bin/pdrive-ui b/bin/pdrive-ui index 3fb8e4e..231b40d 100755 --- a/bin/pdrive-ui +++ b/bin/pdrive-ui @@ -239,7 +239,7 @@ GERMAN_TRANSLATIONS = { "Cache retention": "Cache-Aufbewahrung", "Change cache retention": "Cache-Aufbewahrung ändern", "Active transfers": "Aktive Transfers", - "Progress, speed and ETA directly from rclone’s local RC socket": "Fortschritt, Geschwindigkeit und ETA direkt aus rclones lokalem RC-Socket", + "Progress from rclone; single-file speed and ETA use verified process traffic": "Fortschritt von rclone; Geschwindigkeit und ETA einer Einzeldatei nutzen verifizierten Prozesstraffic", "VFS queue": "VFS-Warteschlange", "Pending uploads remain safe in the existing VFS cache across restarts": "Ausstehende Uploads bleiben auch bei Neustarts sicher im vorhandenen VFS-Cache", "Recently completed": "Kürzlich abgeschlossen", @@ -325,8 +325,10 @@ GERMAN_TRANSLATIONS = { "Status unavailable": "Status nicht erreichbar", "of {slots} upload slots in use": "von {slots} Uploadslots belegt", "{errors} errors · {notices} notices · since reviewed": "{errors} Fehler · {notices} Hinweise · seit der letzten Prüfung", - "{bytes} · ETA ≈ {eta}": "{bytes} · ETA ≈ {eta}", + "{bytes} · ≈{eta}": "{bytes} · ≈{eta}", + "{bytes} remaining · ETA ≈ {eta}": "{bytes} verbleibend · ETA ≈ {eta}", "{bytes} · ETA calculating": "{bytes} · ETA wird berechnet", + "{bytes} · ⏸ ETA waiting": "{bytes} · ⏸ ETA wartet", "Queue is empty · ETA complete": "Warteschlange leer · ETA abgeschlossen", "Mark issues reviewed": "Probleme als geprüft markieren", "Could not update issue review state": "Prüfstatus der Probleme konnte nicht gespeichert werden", @@ -342,11 +344,19 @@ GERMAN_TRANSLATIONS = { "disabled": "aus", "Service {active}/{sub} · PID {pid}": "Dienst {active}/{sub} · PID {pid}", "No details available.": "Keine Detailmeldung verfügbar.", + "Recovering": "Wiederherstellung läuft", + "The retried upload is transferring again with verified process traffic.": "Der erneut versuchte Upload überträgt wieder mit verifiziertem Prozesstraffic.", "Unknown file": "Unbekannte Datei", "Uploading": "Upload läuft", "Waiting": "Wartet", "Attempt {tries}": "Versuch {tries}", "{tries} attempt(s)": "{tries} Versuch(e)", + "Rate unavailable": "Rate nicht verfügbar", + "Verified process traffic": "Verifizierter Prozesstraffic", + "rclone estimate": "rclone-Schätzung", + "ETA calculating": "ETA wird berechnet", + "⏸ ETA waiting": "⏸ ETA wartet", + "No upload traffic is currently available for an estimate.": "Für eine Schätzung ist derzeit kein Uploadtraffic verfügbar.", "Failed": "Fehler", "Completed": "Fertig", "No history yet": "Noch kein Verlauf", @@ -751,6 +761,62 @@ def network_receive_rate( return network_counter_rate(previous, network_io, pid, now, "received_bytes", "receive_speed") +def upload_eta_ready( + samples: int, + rate: float, + last_progress: float, + sample_time: float, + refresh_interval: int, +) -> bool: + stale_after = max(30, refresh_interval * 3) + return samples >= 3 and rate > 0 and sample_time - last_progress <= stale_after + + +def backend_transfer_metrics(item: dict[str, Any]) -> tuple[float, int]: + """Return rclone's estimate only when its speed and ETA agree.""" + + size = max(0, int(item.get("size") or 0)) + done = min(size, max(0, int(item.get("bytes") or 0))) + remaining = max(0, size - done) + speed = max(0.0, float(item.get("speed") or 0)) + eta = int(item.get("eta_seconds") or -1) + if remaining <= 0 or speed < 1024 or eta <= 0: + return 0.0, -1 + calculated = remaining / speed + if calculated <= 0 or max(calculated, eta) / min(calculated, eta) > 4: + return 0.0, -1 + return speed, eta + + +def verified_single_transfer_metrics( + transfers: dict[str, Any], + queue: dict[str, Any], + current_speed: float, + smoothed_rate: float, + estimate_ready: bool, +) -> dict[str, Any] | None: + """Share one validated estimator when exactly one queued transfer matches.""" + + active_items = transfers.get("active", []) + queue_items = queue.get("items", []) + if not isinstance(active_items, list) or not isinstance(queue_items, list): + return None + if int(queue.get("count") or 0) != 1 or len(active_items) != 1 or len(queue_items) != 1: + return None + active = active_items[0] + queued = queue_items[0] + if not isinstance(active, dict) or not isinstance(queued, dict) or not queued.get("uploading"): + return None + if str(active.get("name") or "") != str(queued.get("name") or ""): + return None + remaining = max(0, int(queue.get("remaining_bytes", queue.get("bytes", 0)) or 0)) + return { + "speed": max(0.0, current_speed), + "eta_seconds": int(remaining / smoothed_rate) if estimate_ready and smoothed_rate > 0 else -1, + "estimate_ready": estimate_ready, + } + + def bandwidth_slider_position(rate: str) -> float: component = rate.split(":", 1)[0].strip() if component.lower() in {"", "off", "0"}: @@ -918,6 +984,7 @@ window.pdrive-window { .status-card { border-radius: 16px; padding: 13px 20px; } .page.overview-page { padding-top: 18px; } .status-ready { background: #12382f; border: 1px solid #23876e; } +.status-working { background: #153746; border: 1px solid #2e91b5; } .status-warning { background: #463617; border: 1px solid #b78324; } .status-critical { background: #4a2028; border: 1px solid #bd4559; } .status-unknown { background: #26303e; border: 1px solid #52647c; } @@ -1066,6 +1133,7 @@ button.setup-primary:hover { background-image: linear-gradient(to bottom, #4994b STATUS_META = { "ready": ("emblem-ok-symbolic", "Ready", "good"), + "working": ("emblem-synchronizing-symbolic", "Recovering", "accent"), "warning": ("dialog-warning-symbolic", "Attention", "warning"), "critical": ("dialog-error-symbolic", "Problem", "danger"), "unknown": ("dialog-question-symbolic", "Unknown", "muted"), @@ -1156,6 +1224,18 @@ def clock_duration(value: Any) -> str: return f"{hours:02d}:{minutes:02d}:{seconds:02d}" +def compact_eta_duration(value: Any) -> str: + """Keep extreme near-pause estimates readable in compact overview cards.""" + + try: + seconds = max(0, int(math.ceil(float(value)))) + except (TypeError, ValueError, OverflowError): + return "–" + if seconds >= 100 * 86400: + return f"{math.ceil(seconds / 86400)}d" + return clock_duration(seconds) + + def approximate_duration(value: Any) -> str: try: seconds = max(0, int(value)) @@ -1349,9 +1429,10 @@ class StatCard(Gtk.EventBox): return self.activate_navigation() return False - def update(self, value: str, detail: str = "") -> None: + def update(self, value: str, detail: str = "", detail_tooltip: str | None = None) -> None: self.value.set_text(value) self.detail.set_text(detail) + self.detail.set_tooltip_text(detail_tooltip) def make_compact(self) -> None: add_css(self.frame, "compact-card") @@ -2427,7 +2508,7 @@ class PDriveWindow(Gtk.ApplicationWindow): self.refreshing = False self.closed = False self.content_fit_source = 0 - self.content_fit_attempts = 0 + self.content_fit_completed = False self.documentation_window: DocumentationWindow | None = None self.about_dialog: Gtk.AboutDialog | None = None self.setup_wizard: SetupWizard | None = None @@ -2441,6 +2522,8 @@ class PDriveWindow(Gtk.ApplicationWindow): self.upload_eta_rate = 0.0 self.upload_eta_samples = 0 self.upload_eta_last_progress = 0.0 + self.upload_eta_signature: tuple[tuple[str, int], ...] = () + self.upload_eta_seconds = -1 self.capacity_refresh_due = 0.0 self.remote_capacity: dict[str, Any] = {} self.current_state: dict[str, Any] = {} @@ -2496,6 +2579,7 @@ class PDriveWindow(Gtk.ApplicationWindow): current_child.destroy() self.setup_required = False self.setup_wizard = None + self.content_fit_completed = False self.build_ui() self.set_empty_lists() self.show_all() @@ -3047,7 +3131,7 @@ class PDriveWindow(Gtk.ApplicationWindow): add_css(self.transfers_page, "page") self.active_transfer_section, self.active_list = self.build_list_section( "Active transfers", - "Progress, speed and ETA directly from rclone’s local RC socket", + "Progress from rclone; single-file speed and ETA use verified process traffic", ) self.queue_transfer_section, self.queue_list = self.build_list_section( "VFS queue", @@ -3277,7 +3361,6 @@ class PDriveWindow(Gtk.ApplicationWindow): def schedule_content_fit(self) -> None: if self.closed or self.content_fit_source: return - self.content_fit_attempts = 0 self.content_fit_source = GLib.timeout_add(80, self.fit_content_height) def fit_content_height(self) -> bool: @@ -3287,7 +3370,12 @@ class PDriveWindow(Gtk.ApplicationWindow): if not self.get_visible() or not self.get_mapped(): self.content_fit_source = 0 return GLib.SOURCE_REMOVE - self.content_fit_attempts += 1 + if self.content_fit_completed: + self.content_fit_source = 0 + return GLib.SOURCE_REMOVE + if not self.setup_required and not self.current_state: + self.content_fit_source = 0 + return GLib.SOURCE_REMOVE overflow = 0.0 if not self.setup_required and hasattr(self, "overview_scroller"): adjustment = self.overview_scroller.get_vadjustment() @@ -3307,8 +3395,7 @@ class PDriveWindow(Gtk.ApplicationWindow): target_height = min(maximum_height, height + math.ceil(overflow) + 4) if target_height > height: self.resize(width, target_height) - if self.content_fit_attempts < 4 and overflow > 0.5: - return GLib.SOURCE_CONTINUE + self.content_fit_completed = True self.content_fit_source = 0 return GLib.SOURCE_REMOVE @@ -3324,17 +3411,25 @@ class PDriveWindow(Gtk.ApplicationWindow): 0, int(queue.get("remaining_bytes", queue.get("bytes", 0)) or 0), ) + queue_items = queue.get("items", []) + signature = tuple( + (str(item.get("name") or ""), int(item.get("size") or 0)) for item in queue_items if isinstance(item, dict) + ) if count == 0: self.upload_eta_pid = pid self.upload_eta_rate = 0.0 self.upload_eta_samples = 0 self.upload_eta_last_progress = 0.0 + self.upload_eta_signature = () + self.upload_eta_seconds = -1 return translate("Queue is empty · ETA complete") - if pid != self.upload_eta_pid: + if pid != self.upload_eta_pid or signature != self.upload_eta_signature: self.upload_eta_pid = pid self.upload_eta_rate = 0.0 self.upload_eta_samples = 0 self.upload_eta_last_progress = 0.0 + self.upload_eta_signature = signature + self.upload_eta_seconds = -1 if int(queue.get("active") or 0) > 0 and speed >= 1024: if self.upload_eta_samples == 0: self.upload_eta_rate = speed @@ -3342,21 +3437,29 @@ class PDriveWindow(Gtk.ApplicationWindow): self.upload_eta_rate = (self.upload_eta_rate * 0.8) + (speed * 0.2) self.upload_eta_samples += 1 self.upload_eta_last_progress = sample_time - stale_after = max(30, self.refresh_interval_seconds() * 3) - estimate_ready = ( - self.upload_eta_samples >= 3 - and self.upload_eta_rate > 0 - and sample_time - self.upload_eta_last_progress <= stale_after + estimate_ready = upload_eta_ready( + self.upload_eta_samples, + self.upload_eta_rate, + self.upload_eta_last_progress, + sample_time, + self.refresh_interval_seconds(), ) if not estimate_ready: + self.upload_eta_seconds = -1 + if speed < 1024: + return translate_format( + "{bytes} · ⏸ ETA waiting", + bytes=human_bytes(remaining), + ) return translate_format( "{bytes} · ETA calculating", bytes=human_bytes(remaining), ) + self.upload_eta_seconds = int(remaining / self.upload_eta_rate) return translate_format( - "{bytes} · ETA ≈ {eta}", + "{bytes} · ≈{eta}", bytes=human_bytes(remaining), - eta=clock_duration(remaining / self.upload_eta_rate), + eta=compact_eta_duration(self.upload_eta_seconds), ) def on_refresh_timer(self) -> bool: @@ -3433,11 +3536,6 @@ class PDriveWindow(Gtk.ApplicationWindow): self.remote_capacity = remote_capacity status = str(health.get("status", "unknown")) - self.update_status( - status, - translate(STATUS_META.get(status, STATUS_META["unknown"])[1]), - self.localized_health_summary(state), - ) self.updated_label.set_text(dt.datetime.now().strftime("%H:%M:%S")) sample_time = time.monotonic() @@ -3473,7 +3571,36 @@ class PDriveWindow(Gtk.ApplicationWindow): int(service.get("pid") or 0), sample_time, ) - self.queue_card.update(str(queue.get("count", 0)), queue_detail) + queue_tooltip = queue_detail + if self.upload_eta_seconds >= 0: + queue_tooltip = translate_format( + "{bytes} remaining · ETA ≈ {eta}", + bytes=human_bytes(queue.get("remaining_bytes", queue.get("bytes", 0))), + eta=clock_duration(self.upload_eta_seconds), + ) + self.queue_card.update(str(queue.get("count", 0)), queue_detail, queue_tooltip) + live_status = status + live_summary = self.localized_health_summary(state) + if ( + status == "warning" + and str(health.get("reason_code") or "") == "persistent-upload-failure" + and int(queue.get("active") or 0) > 0 + and int(queue.get("max_tries") or 0) >= 2 + and upload_eta_ready( + self.upload_eta_samples, + self.upload_eta_rate, + self.upload_eta_last_progress, + sample_time, + self.refresh_interval_seconds(), + ) + ): + live_status = "working" + live_summary = translate("The retried upload is transferring again with verified process traffic.") + self.update_status( + live_status, + translate(STATUS_META.get(live_status, STATUS_META["unknown"])[1]), + live_summary, + ) issues = state.get("issues", {}) application = self.get_application() if ( @@ -3519,8 +3646,8 @@ class PDriveWindow(Gtk.ApplicationWindow): "Everything is in sync. The service keeps its fast metadata cache warm and waits for changes." ) self.live_summary.set_text(summary) - if status in {"warning", "critical"}: - summary_icon, _summary_title, summary_color = STATUS_META[status] + if live_status in {"warning", "critical", "working"}: + summary_icon, _summary_title, summary_color = STATUS_META[live_status] elif active or queue.get("count", 0): summary_icon, summary_color = "emblem-synchronizing-symbolic", "accent" else: @@ -3631,7 +3758,7 @@ class PDriveWindow(Gtk.ApplicationWindow): ) ) - self.update_transfers(transfers, queue) + self.update_transfers(transfers, queue, speed, sample_time) self.update_cache_details(vfs, config) self.update_history(state.get("history", [])) self.status_title.set_tooltip_text( @@ -3644,7 +3771,19 @@ class PDriveWindow(Gtk.ApplicationWindow): ) application = self.get_application() if isinstance(application, PDriveApplication): - application.update_indicator(state, speed) + indicator_state = state + if live_status != status: + indicator_state = { + **state, + "health": { + **health, + "status": live_status, + "summary": live_summary, + }, + } + application.update_indicator(indicator_state, speed) + if not self.content_fit_completed: + self.schedule_content_fit() return GLib.SOURCE_REMOVE def update_issue_card(self, issues: dict[str, Any]) -> None: @@ -3843,6 +3982,7 @@ class PDriveWindow(Gtk.ApplicationWindow): def update_status(self, status: str, title_text: str, summary: str) -> None: for css_class in ( "status-ready", + "status-working", "status-warning", "status-critical", "status-unknown", @@ -3873,9 +4013,20 @@ class PDriveWindow(Gtk.ApplicationWindow): text = "Failed" if item.get("error") else "Completed" top.pack_end(label(text, "pill"), False, False, 0) else: - top.pack_end(label(human_rate(item.get("speed", 0)), "pill"), False, False, 0) + if "display_speed" in item: + display_speed = max(0.0, float(item.get("display_speed") or 0)) + speed_text = human_rate(display_speed) + speed_source = translate("Verified process traffic") + else: + display_speed, _backend_eta = backend_transfer_metrics(item) + speed_text = human_rate(display_speed) if display_speed > 0 else translate("Rate unavailable") + speed_source = translate("rclone estimate") + speed_pill = label(speed_text, "pill") + speed_pill.set_tooltip_text(speed_source) + top.pack_end(speed_pill, False, False, 0) box.pack_start(top, False, False, 0) + detail_tooltip: str | None = None if not queue and not recent: size = int(item.get("size") or 0) done = int(item.get("bytes") or 0) @@ -3883,29 +4034,80 @@ class PDriveWindow(Gtk.ApplicationWindow): progress = Gtk.ProgressBar(fraction=fraction) box.pack_start(progress, False, False, 0) detail = f"{human_bytes(done)} / {human_bytes(size)}" - eta = human_duration(item.get("eta_seconds", -1)) + eta_seconds = item.get("display_eta_seconds", item.get("eta_seconds", -1)) + if "display_eta_seconds" not in item: + _backend_speed, eta_seconds = backend_transfer_metrics(item) + eta = ( + compact_eta_duration(eta_seconds) + if item.get("display_eta_approximate") and int(eta_seconds or -1) >= 0 + else human_duration(eta_seconds) + ) if eta != "–": - detail += f" · ETA {eta}" + prefix = "≈" if item.get("display_eta_approximate") else "ETA " + detail += f" · {prefix}{eta}" + if item.get("display_eta_approximate"): + detail_tooltip = f"{human_bytes(done)} / {human_bytes(size)} · ETA ≈ {clock_duration(eta_seconds)}" + elif item.get("display_eta_calculating"): + detail += f" · {translate('ETA calculating')}" + elif item.get("display_eta_waiting"): + detail += f" · {translate('⏸ ETA waiting')}" + detail_tooltip = translate("No upload traffic is currently available for an estimate.") elif queue: detail = f"{human_bytes(item.get('size', 0))} · " + translate_format( "{tries} attempt(s)", tries=item.get("tries", 0) ) else: detail = f"{human_bytes(item.get('bytes') or item.get('size', 0))} · {local_time(str(item.get('completed_at', '')))}" - box.pack_start(label(detail, "transfer-meta"), False, False, 0) + detail_label = label(detail, "transfer-meta") + detail_label.set_tooltip_text(detail_tooltip) + box.pack_start(detail_label, False, False, 0) row.add(box) return row - def update_transfers(self, transfers: dict[str, Any], queue: dict[str, Any]) -> None: + def update_transfers( + self, + transfers: dict[str, Any], + queue: dict[str, Any], + current_speed: float = 0.0, + sample_time: float | None = None, + ) -> None: self.clear_list(self.active_list) self.clear_list(self.queue_list) self.clear_list(self.recent_list) active_items = transfers.get("active", []) queue_items = queue.get("items", []) recent_items = transfers.get("recent", []) + estimate_ready = upload_eta_ready( + self.upload_eta_samples, + self.upload_eta_rate, + self.upload_eta_last_progress, + sample_time if sample_time is not None else time.monotonic(), + self.refresh_interval_seconds(), + ) + verified_metrics = verified_single_transfer_metrics( + transfers, + queue, + current_speed, + self.upload_eta_rate, + estimate_ready, + ) if active_items: for item in active_items: - self.active_list.add(self.transfer_row(item)) + display_item = item + if verified_metrics is not None: + display_item = { + **item, + "display_speed": verified_metrics["speed"], + "display_eta_seconds": verified_metrics["eta_seconds"], + "display_eta_approximate": verified_metrics["estimate_ready"], + "display_eta_calculating": ( + not verified_metrics["estimate_ready"] and verified_metrics["speed"] >= 1024 + ), + "display_eta_waiting": ( + not verified_metrics["estimate_ready"] and verified_metrics["speed"] < 1024 + ), + } + self.active_list.add(self.transfer_row(display_item)) else: uploading = [item for item in queue_items if item.get("uploading")] if uploading: @@ -4785,6 +4987,7 @@ class PDriveApplication(Gtk.Application): width, _height = self.window.get_size() initial_height = SETUP_WINDOW_HEIGHT if self.window.setup_required else DEFAULT_WINDOW_HEIGHT self.window.resize(max(MIN_WINDOW_WIDTH, width), initial_height) + self.window.content_fit_completed = False self.window.show_all() self.window.present() self.window.schedule_content_fit() diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 638a8ff..42785bc 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -106,7 +106,9 @@ display is unavailable: ``` Exercise both languages, minimum width, zero and active transfer fixtures, -Preferences dirty-state behavior, issue review and content-height fitting. +Preferences dirty-state behavior, issue review and content-height fitting. Use +an implausible stale rclone speed/ETA pair beside zero process-owned TCP traffic +as a regression fixture; the active row must not present it as live activity. Never run the state-writing watchdog against a live deployment from a sandbox that cannot access its user bus, FUSE mount or RC socket. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 0da7dc7..c7dc465 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -234,11 +234,23 @@ derived from the selected live-metrics interval and the 150 retained samples, so changing the interval updates both the poll note and displayed time range. The Queue card shows the complete remaining VFS backlog, subtracting bytes -already reported for each matching active transfer. Its `HH:MM:SS` ETA uses an -exponentially smoothed process-owned upload rate. PDrive waits for three useful -samples and returns to **ETA calculating** after a prolonged traffic gap; it -never turns an idle API request or a stale transfer counter into a precise -completion promise. Multi-day and 100-GiB uploads retain their full hour count. +already reported for each matching active transfer. Its ETA uses an +exponentially smoothed process-owned upload rate. When exactly one queued file +matches one active transfer, both views share that estimator, so they cannot +show contradictory speeds or completion times. PDrive waits for three useful +samples before showing a number. While useful bytes are flowing but the sample +window is still young it shows **ETA calculating**; without current traffic it +shows **⏸ ETA waiting**. It never turns an idle API request or a stale rclone +transfer counter into a precise completion promise. Normal estimates use `HH:MM:SS`, multi-day uploads +show days and hours, and extreme near-pause estimates above 100 days use a +rounded day count in compact cards. Hovering the value reveals the fuller +estimate. + +A previous upload failure remains available in History and issue review while +the retry is being observed. After three fresh process-owned traffic samples, +the live banner changes from **Attention** to **Recovering**. It falls back to +the conservative warning when traffic becomes stale and never overrides an +unrelated critical condition. The Capacity card separates the Proton account from the local cache filesystem. It shows Proton cloud used, total and free values exposed by the mounted remote beside @@ -358,7 +370,9 @@ or a one-pixel theme difference while remaining independent of GTK shadow and header-bar dimensions. A session-autostart window stays hidden until the user opens it; hidden, unmapped allocations are never used for content fitting. Opening it restores the compact content height before the same visible-window -fit runs. Smaller screens retain normal scrolling. +fit runs. Each opening can request at most one growth resize after the first +dashboard state arrives, so an allocation that has not caught up cannot add the +same overflow repeatedly. Smaller screens retain normal scrolling. On X11 Cinnamon the tray uses GTK StatusIcon so a left click opens/focuses the Control Center and a right click opens the existing Open, Open `/pdrive`, and diff --git a/docs/assets/pdrive-control-center.png b/docs/assets/pdrive-control-center.png index 2fda68f..75c83b7 100644 Binary files a/docs/assets/pdrive-control-center.png and b/docs/assets/pdrive-control-center.png differ diff --git a/docs/assets/pdrive-transfers.png b/docs/assets/pdrive-transfers.png index c2d28c1..cc1bf11 100644 Binary files a/docs/assets/pdrive-transfers.png and b/docs/assets/pdrive-transfers.png differ diff --git a/tests/test-ui-preferences.sh b/tests/test-ui-preferences.sh index 9bfa252..eef4392 100755 --- a/tests/test-ui-preferences.sh +++ b/tests/test-ui-preferences.sh @@ -204,6 +204,8 @@ class EtaTracker: upload_eta_rate = 0.0 upload_eta_samples = 0 upload_eta_last_progress = 0.0 + upload_eta_signature = () + upload_eta_seconds = -1 @staticmethod def refresh_interval_seconds(): @@ -219,8 +221,68 @@ module.PDriveWindow.queue_eta_detail(eta_tracker, near_pause_queue, 20 * 1024, 4 near_pause_eta = module.PDriveWindow.queue_eta_detail( eta_tracker, near_pause_queue, 20 * 1024, 4242, 14.0 ) -assert "ETA ≈" in near_pause_eta +assert "≈" in near_pause_eta assert "calculating" not in near_pause_eta +assert module.compact_eta_duration(99 * 86400) == "99d 0h" +assert module.compact_eta_duration((100 * 86400) + 1) == "101d" +assert module.upload_eta_ready(3, 20 * 1024, 14.0, 14.0, 2) +assert not module.upload_eta_ready(2, 20 * 1024, 14.0, 14.0, 2) +assert not module.upload_eta_ready(3, 20 * 1024, 14.0, 45.0, 2) + +waiting_tracker = EtaTracker() +waiting_eta = module.PDriveWindow.queue_eta_detail( + waiting_tracker, near_pause_queue, 0, 4242, 10.0 +) +assert "⏸ ETA waiting" in waiting_eta + +single_name = "demo/large.img" +single_queue = { + "count": 1, + "active": 1, + "bytes": 20 * 1024 * 1024, + "remaining_bytes": 10 * 1024 * 1024, + "items": [{"name": single_name, "size": 20 * 1024 * 1024, "uploading": True}], +} +single_transfers = { + "active": [ + { + "name": single_name, + "size": 20 * 1024 * 1024, + "bytes": 10 * 1024 * 1024, + "speed": 900 * 1024, + "eta_seconds": 999999, + } + ] +} +single_metrics = module.verified_single_transfer_metrics( + single_transfers, + single_queue, + 20 * 1024, + 20 * 1024, + True, +) +assert single_metrics == {"speed": 20 * 1024, "eta_seconds": 512, "estimate_ready": True} +assert module.backend_transfer_metrics(single_transfers["active"][0]) == (0.0, -1) +assert module.backend_transfer_metrics({**single_transfers["active"][0], "eta_seconds": 0}) == (0.0, -1) +assert module.verified_single_transfer_metrics( + {"active": single_transfers["active"] * 2}, + {**single_queue, "count": 2}, + 20 * 1024, + 20 * 1024, + True, +) is None + +identity_tracker = EtaTracker() +for sample_time in (10.0, 12.0, 14.0): + module.PDriveWindow.queue_eta_detail(identity_tracker, single_queue, 20 * 1024, 4242, sample_time) +assert identity_tracker.upload_eta_samples == 3 +changed_queue = { + **single_queue, + "items": [{"name": "demo/replacement.img", "size": 20 * 1024 * 1024, "uploading": True}], +} +changed_detail = module.PDriveWindow.queue_eta_detail(identity_tracker, changed_queue, 20 * 1024, 4242, 16.0) +assert "calculating" in changed_detail +assert identity_tracker.upload_eta_samples == 1 assert module.bandwidth_slider_position("off") == module.BANDWIDTH_SLIDER_UNLIMITED assert module.bandwidth_slider_position("0") == module.BANDWIDTH_SLIDER_UNLIMITED diff --git a/tests/test-ui-widgets.sh b/tests/test-ui-widgets.sh index 5e099d5..3cf245b 100755 --- a/tests/test-ui-widgets.sh +++ b/tests/test-ui-widgets.sh @@ -21,6 +21,7 @@ import importlib.machinery import importlib.util import copy import sys +import time loader = importlib.machinery.SourceFileLoader("pdrive_ui_widget_test", sys.argv[1]) spec = importlib.util.spec_from_loader(loader.name, loader) @@ -276,9 +277,91 @@ assert "used" in window.capacity_card.remote_detail.get_text() assert "free" in window.capacity_card.local_value.get_text() assert "VFS cache used" in window.capacity_card.local_detail.get_text() assert "pending upload" in window.cache_card.detail.get_text() -assert "ETA " in window.queue_card.detail.get_text() +assert "≈" in window.queue_card.detail.get_text() assert "calculating" not in window.queue_card.detail.get_text() +recovery_state = copy.deepcopy(module.demo_state()) +recovery_active = recovery_state["transfers"]["active"][0] +recovery_active["speed"] = 897.4 * 1024 +recovery_active["eta_seconds"] = 9_223_372_036 +recovery_state["queue"].update( + { + "count": 1, + "active": 1, + "failed": 1, + "max_tries": 2, + "bytes": recovery_active["size"], + "remaining_bytes": recovery_active["size"] - recovery_active["bytes"], + "items": [ + { + "name": recovery_active["name"], + "size": recovery_active["size"], + "tries": 2, + "uploading": True, + } + ], + } +) +recovery_state["health"].update( + { + "status": "warning", + "reason_code": "persistent-upload-failure", + "summary": "At least one file remains queued after multiple upload attempts.", + } +) +recovery_state["network_io"]["send_speed"] = 4 * 1024 * 1024 +for _sample in range(3): + window.apply_state(recovery_state) +assert window.status_title.get_text() == "Recovering" +assert window.status_frame.get_style_context().has_class("status-working") +assert "verified process traffic" in window.status_summary.get_text() +queue_eta = window.queue_card.detail.get_text().split("≈", 1)[1] +active_labels = [ + widget.get_text() + for widget in descendants(window.active_list) + if isinstance(widget, module.Gtk.Label) +] +assert "4.0 MiB/s" in active_labels +active_detail = next(text for text in active_labels if "· ≈" in text) +assert active_detail.endswith(queue_eta), (active_detail, queue_eta) +assert not any("897.4 KiB/s" in text for text in active_labels) +assert "ETA ≈" in window.queue_card.detail.get_tooltip_text() + +stalled_state = copy.deepcopy(recovery_state) +stalled_state["network_io"]["send_speed"] = 0 +window.upload_eta_last_progress = time.monotonic() - 31 +window.apply_state(stalled_state) +assert window.status_title.get_text() == "Attention" +assert "⏸ ETA waiting" in window.queue_card.detail.get_text() +stalled_labels = [ + widget.get_text() + for widget in descendants(window.active_list) + if isinstance(widget, module.Gtk.Label) +] +assert "0 B/s" in stalled_labels +assert any("⏸ ETA waiting" in text for text in stalled_labels) + +window.upload_eta_pid = 0 +window.upload_eta_signature = () +window.upload_eta_samples = 0 +window.upload_eta_rate = 0 +window.upload_eta_last_progress = 0 +window.apply_state(recovery_state) +assert window.status_title.get_text() == "Attention" + +critical_state = copy.deepcopy(recovery_state) +critical_state["health"].update( + { + "status": "critical", + "reason_code": "service-inactive", + "summary": "The Proton Drive service is inactive.", + } +) +window.apply_state(critical_state) +window.apply_state(critical_state) +window.apply_state(critical_state) +assert window.status_title.get_text() == "Problem" + stress_state = copy.deepcopy(module.demo_state()) stress_state["queue"].update( { @@ -299,11 +382,13 @@ assert window.queue_card.value.get_text() == "12345" assert not window.queue_card.value.get_layout().is_ellipsized() queue_stress_detail = window.queue_card.detail.get_text() assert "12.0 TiB" in queue_stress_detail, queue_stress_detail -assert "ETA ≈" in queue_stress_detail, queue_stress_detail +assert "≈" in queue_stress_detail, queue_stress_detail assert not window.queue_card.detail.get_layout().is_ellipsized(), ( queue_stress_detail, window.queue_card.detail.get_allocated_width(), ) +assert queue_stress_detail.endswith("d"), queue_stress_detail +assert "ETA ≈" in window.queue_card.detail.get_tooltip_text() assert window.retention_button.get_sensitive() assert window.retention_button.get_tooltip_text() == "Change cache retention" assert window.retention_button.get_events() & module.Gdk.EventMask.ENTER_NOTIFY_MASK @@ -504,6 +589,65 @@ assert opened_configuration_dialogs == [ ] assert module.tray_supports_distinct_clicks() + + +class FixedAdjustment: + @staticmethod + def get_upper(): + return 820 + + @staticmethod + def get_page_size(): + return 720 + + +class FixedScroller: + @staticmethod + def get_vadjustment(): + return FixedAdjustment() + + +class ContentFitTracker: + closed = False + setup_required = False + current_state = {"health": {"status": "ready"}} + content_fit_source = 1 + content_fit_completed = False + overview_scroller = FixedScroller() + root = None + + def __init__(self): + self.resizes = [] + + @staticmethod + def get_visible(): + return True + + @staticmethod + def get_mapped(): + return True + + @staticmethod + def get_size(): + return (820, 720) + + @staticmethod + def get_window(): + return None + + def resize(self, width, height): + self.resizes.append((width, height)) + + +fit_tracker = ContentFitTracker() +assert module.PDriveWindow.fit_content_height(fit_tracker) == module.GLib.SOURCE_REMOVE +assert fit_tracker.resizes == [(820, 824)] +assert fit_tracker.content_fit_completed +# Reusing the stale adjustment cannot compound the first resize. +fit_tracker.content_fit_source = 1 +assert module.PDriveWindow.fit_content_height(fit_tracker) == module.GLib.SOURCE_REMOVE +assert fit_tracker.resizes == [(820, 824)] + app.demo = False window.demo = False refreshes = []