You've already forked OneDriveGUI
mirror of
https://github.com/bpozdena/OneDriveGUI.git
synced 2026-10-24 04:59:17 +03:00
Use the client's monitor-status.json for the high-level sync state
Client v2.5.12 publishes a machine-readable runtime status document, monitor-status.json, in the configuration directory while running in --monitor mode. OneDriveGUI now polls it every 2 seconds for each running profile and uses it as the authoritative high-level state, while the client output stays the source for per-file activity and progress. - The document is only used when its schema_version is 1 and its pid is the client process started by the worker, so a stale file left behind by a crashed or killed client is ignored. - The status label is rewritten only when the high-level state changes (idle with the local time of the last successful sync, partial failure with the failure count, service unreachable, system clock drift blocking sync, interrupted), so detailed messages from the client output are not overwritten on every poll. - The status label tooltip summarises the document: client state and loop, last successful sync, cycle count and result, failure counts, OneDrive reachability and system time state. - The tray icon uses the document's state (syncing, idle, degraded) instead of guessing from the status text. UTC timestamps are converted to local time by hand, as the client emits 7 fractional digits which datetime.fromisoformat() rejects before Python 3.11 (the AppImage ships Python 3.10). Clients older than v2.5.12, or profiles with monitor_status disabled, keep using the status derived from the client output. Refs #305
This commit is contained in:
@@ -42,7 +42,7 @@ from .options import (
|
||||
)
|
||||
|
||||
from .utils.utils import humanize_file_size, shorten_path, format_relative_time
|
||||
from .workers import WorkerThread, MaintenanceWorker, TaskList, workers
|
||||
from .workers import WorkerThread, MaintenanceWorker, TaskList, workers, parse_status_timestamp
|
||||
from .gui_settings_window import gui_settings_window
|
||||
|
||||
import logging
|
||||
@@ -222,6 +222,14 @@ class MainWindow(QMainWindow, Ui_MainWindow):
|
||||
self.auto_sync.timeout.connect(self.autostart_monitor)
|
||||
self.auto_sync.start(1000)
|
||||
|
||||
# Poll the machine-readable runtime status published by client v2.5.12+
|
||||
# (monitor-status.json). Older clients do not publish it, in which case
|
||||
# the status keeps being derived from the client output alone.
|
||||
self.refresh_monitor_status = QTimer()
|
||||
self.refresh_monitor_status.setSingleShot(False)
|
||||
self.refresh_monitor_status.timeout.connect(self.update_monitor_status)
|
||||
self.refresh_monitor_status.start(2000)
|
||||
|
||||
# Timer to refresh relative times for completed transfers
|
||||
self.refresh_relative_times = QTimer()
|
||||
self.refresh_relative_times.setSingleShot(False)
|
||||
@@ -555,6 +563,122 @@ class MainWindow(QMainWindow, Ui_MainWindow):
|
||||
# Update system tray icon based on aggregate status
|
||||
self.update_tray_icon()
|
||||
|
||||
def update_monitor_status(self):
|
||||
"""
|
||||
Apply the client's monitor-status.json to each running profile. The file
|
||||
is the authoritative source for the high-level state (syncing, idle,
|
||||
degraded, reachability, system time); per-file activity still comes from
|
||||
the client output. The status label is only rewritten when the
|
||||
high-level state changes, so detailed output messages are not overwritten
|
||||
on every poll.
|
||||
"""
|
||||
for profile_name, page in self.profile_status_pages.items():
|
||||
worker = workers.get(profile_name)
|
||||
status = worker.monitor_status() if worker is not None and worker.isRunning() else None
|
||||
page.monitor_status = status
|
||||
|
||||
if status is None:
|
||||
if page.monitor_status_key is not None:
|
||||
page.monitor_status_key = None
|
||||
page.label_onedrive_status.setToolTip("")
|
||||
continue
|
||||
|
||||
process = status.get("process", {})
|
||||
sync = status.get("sync", {})
|
||||
last_cycle = sync.get("last_cycle", {})
|
||||
conditions = status.get("conditions", {})
|
||||
|
||||
page.label_onedrive_status.setToolTip(self._monitor_status_tooltip(status))
|
||||
|
||||
key = (
|
||||
process.get("state"),
|
||||
last_cycle.get("result"),
|
||||
conditions.get("onedrive_reachability"),
|
||||
conditions.get("system_time"),
|
||||
process.get("monitor_loop"),
|
||||
)
|
||||
if key == page.monitor_status_key:
|
||||
continue
|
||||
page.monitor_status_key = key
|
||||
|
||||
message = self._monitor_status_message(status)
|
||||
if message:
|
||||
logging.info(f"[{profile_name}] Monitor status: {message}")
|
||||
page.label_onedrive_status.setText(message)
|
||||
|
||||
@staticmethod
|
||||
def _monitor_status_message(status):
|
||||
"""Status label text for a monitor status document, or None to keep the
|
||||
message derived from the client output (e.g. per-file progress)."""
|
||||
process = status.get("process", {})
|
||||
sync = status.get("sync", {})
|
||||
last_cycle = sync.get("last_cycle", {})
|
||||
conditions = status.get("conditions", {})
|
||||
state = process.get("state")
|
||||
result = last_cycle.get("result")
|
||||
|
||||
if conditions.get("system_time") == "TIME_DRIFT_BLOCKING":
|
||||
return "Sync paused: the system clock differs from Microsoft service time.\nPlease correct the system time."
|
||||
if conditions.get("onedrive_reachability") == "unreachable":
|
||||
return "Cannot connect to Microsoft OneDrive Service.\nThe client will retry automatically."
|
||||
if state == "idle":
|
||||
last_sync = parse_status_timestamp(sync.get("last_successful_sync_at"))
|
||||
if last_sync is not None:
|
||||
return f"OneDrive sync is complete (last sync {last_sync.strftime('%H:%M')})."
|
||||
return "OneDrive sync is complete."
|
||||
if state == "degraded":
|
||||
if result == "partial_failure":
|
||||
failures = sum(int(last_cycle.get(k) or 0) for k in ("upload_failures", "download_failures", "recycle_bin_failures"))
|
||||
return f"Sync completed with {failures} failed item(s)"
|
||||
if result == "service_unreachable":
|
||||
return "Cannot connect to Microsoft OneDrive Service.\nThe client will retry automatically."
|
||||
if result == "time_unsafe":
|
||||
return "Sync paused: the system clock differs from Microsoft service time.\nPlease correct the system time."
|
||||
if result == "interrupted":
|
||||
return "The last sync was interrupted."
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _monitor_status_tooltip(status):
|
||||
"""Hover text for the status label summarising the monitor status document."""
|
||||
process = status.get("process", {})
|
||||
sync = status.get("sync", {})
|
||||
last_cycle = sync.get("last_cycle", {})
|
||||
conditions = status.get("conditions", {})
|
||||
|
||||
def local_time(value):
|
||||
timestamp = parse_status_timestamp(value)
|
||||
return timestamp.strftime("%Y-%m-%d %H:%M:%S") if timestamp else "never"
|
||||
|
||||
lines = [
|
||||
f"Client state: {process.get('state', 'unknown')} (monitor loop {process.get('monitor_loop', 0)})",
|
||||
f"Last successful sync: {local_time(sync.get('last_successful_sync_at'))}",
|
||||
f"Successful sync cycles: {sync.get('successful_cycle_count', 0)}",
|
||||
f"Last cycle result: {str(last_cycle.get('result', 'never_run')).replace('_', ' ')}",
|
||||
]
|
||||
failures = {k: int(last_cycle.get(f"{k}_failures") or 0) for k in ("upload", "download", "recycle_bin")}
|
||||
if any(failures.values()):
|
||||
lines.append("Failed: " + ", ".join(f"{count} {kind.replace('_', ' ')}" for kind, count in failures.items() if count))
|
||||
lines.append(f"Microsoft OneDrive: {conditions.get('onedrive_reachability', 'unknown')}")
|
||||
lines.append(f"System time: {conditions.get('system_time', 'unknown')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _monitor_tray_state(page):
|
||||
"""Tray state derived from the monitor status document, or None when not available."""
|
||||
status = getattr(page, "monitor_status", None)
|
||||
if not status:
|
||||
return None
|
||||
state = status.get("process", {}).get("state")
|
||||
conditions = status.get("conditions", {})
|
||||
if state == "degraded" or conditions.get("onedrive_reachability") == "unreachable" or conditions.get("system_time") == "TIME_DRIFT_BLOCKING":
|
||||
return "ERROR"
|
||||
if state in ("starting", "syncing"):
|
||||
return "SYNCING"
|
||||
if state == "idle":
|
||||
return "IDLE"
|
||||
return None
|
||||
|
||||
def is_ignorable_error(self, error_text):
|
||||
"""
|
||||
Check if an error message should be ignored for tray icon status.
|
||||
@@ -608,17 +732,24 @@ class MainWindow(QMainWindow, Ui_MainWindow):
|
||||
error_tooltip = ""
|
||||
free_space = ""
|
||||
|
||||
# High-level state from monitor-status.json (client v2.5.12+), if available
|
||||
monitor_state = self._monitor_tray_state(self.profile_status_pages[profile_name]) if profile_name in self.profile_status_pages else None
|
||||
|
||||
# Categorize this profile's state
|
||||
if not is_running:
|
||||
profile_state = "STOPPED"
|
||||
has_stopped = True
|
||||
elif (not self.is_ignorable_error(error_tooltip) and not self.is_ignorable_error(status_msg)) and (
|
||||
error_tooltip or "Error:" in status_msg or "error" in status_msg.lower() or "failed" in status_msg.lower() or "crashed" in status_msg.lower()
|
||||
elif monitor_state == "ERROR" or (
|
||||
(not self.is_ignorable_error(error_tooltip) and not self.is_ignorable_error(status_msg))
|
||||
and (error_tooltip or "Error:" in status_msg or "error" in status_msg.lower() or "failed" in status_msg.lower() or "crashed" in status_msg.lower())
|
||||
):
|
||||
profile_state = "ERROR"
|
||||
has_error = True
|
||||
running_count += 1
|
||||
elif "sync in progress" in status_msg.lower() or "processing" in status_msg.lower() or "starting" in status_msg.lower() or "initializing" in status_msg.lower():
|
||||
elif monitor_state == "SYNCING" or (
|
||||
monitor_state is None
|
||||
and ("sync in progress" in status_msg.lower() or "processing" in status_msg.lower() or "starting" in status_msg.lower() or "initializing" in status_msg.lower())
|
||||
):
|
||||
profile_state = "SYNCING"
|
||||
has_syncing = True
|
||||
running_count += 1
|
||||
@@ -1614,6 +1745,11 @@ class ProfileStatusPage(QWidget, Ui_status_page):
|
||||
# rows are hidden until the user toggles the failed-files button.
|
||||
self.open_rows = {}
|
||||
self.show_failed_entries = False
|
||||
|
||||
# Last monitor-status.json document of the running client (v2.5.12+) and
|
||||
# the high-level state it was last applied for.
|
||||
self.monitor_status = None
|
||||
self.monitor_status_key = None
|
||||
self.pushButton_show_errors.hide()
|
||||
self.pushButton_show_errors.toggled.connect(self.toggle_failed_entries)
|
||||
|
||||
|
||||
@@ -10,9 +10,10 @@ from .ui.ui_list_item_widget import Ui_list_item_widget
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .global_config import save_global_config
|
||||
from .options import (
|
||||
@@ -38,6 +39,47 @@ from .global_config import DIR_PATH, PROFILES_FILE
|
||||
EXIT_RESYNC_REQUIRED = (78, 126)
|
||||
|
||||
|
||||
MONITOR_STATUS_FILE = "monitor-status.json"
|
||||
MONITOR_STATUS_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def read_monitor_status(config_dir, pid):
|
||||
"""
|
||||
Read the machine-readable runtime status the client (v2.5.12+) publishes in
|
||||
its configuration directory while running in --monitor mode.
|
||||
|
||||
Returns the parsed document, or None when it is missing, unreadable, of an
|
||||
unknown schema version, or owned by a different process. The file is removed
|
||||
on a clean shutdown but can be left behind after a crash or SIGKILL, so the
|
||||
pid must match the client process this worker started.
|
||||
"""
|
||||
try:
|
||||
with open(os.path.join(config_dir, MONITOR_STATUS_FILE), "r", encoding="utf-8") as status_file:
|
||||
status = json.load(status_file)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
if not isinstance(status, dict) or status.get("schema_version") != MONITOR_STATUS_SCHEMA_VERSION or status.get("pid") != pid:
|
||||
return None
|
||||
return status
|
||||
|
||||
|
||||
def parse_status_timestamp(value):
|
||||
"""
|
||||
Convert a monitor status UTC timestamp (e.g. '2026-10-01T21:43:26.9855399Z')
|
||||
to a local, naive datetime. Returns None for empty or malformed values.
|
||||
Fractional seconds are trimmed by hand because the client emits 7 digits,
|
||||
which datetime.fromisoformat() does not accept before Python 3.11.
|
||||
"""
|
||||
match = re.match(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?Z$", value or "")
|
||||
if not match:
|
||||
return None
|
||||
timestamp = datetime.strptime(match.group(1), "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
|
||||
if match.group(2):
|
||||
timestamp = timestamp.replace(microsecond=int(match.group(2)[:6].ljust(6, "0")))
|
||||
return timestamp.astimezone().replace(tzinfo=None)
|
||||
|
||||
|
||||
def _path_key(path):
|
||||
"""Key for per-file lookups. The client prints the same file both with and
|
||||
without a leading './' depending on the message."""
|
||||
@@ -72,6 +114,13 @@ class WorkerThread(QThread):
|
||||
logging.info(f"[GUI] Monitoring command: '{self._command}'")
|
||||
self.profile_name = profile
|
||||
|
||||
def monitor_status(self):
|
||||
"""Current monitor-status.json document of this worker's client, or None."""
|
||||
process = getattr(self, "onedrive_process", None)
|
||||
if process is None or process.poll() is not None:
|
||||
return None
|
||||
return read_monitor_status(self.config_dir.group(1), process.pid)
|
||||
|
||||
def stop_worker(self):
|
||||
logging.info(f"[{self.profile_name}] Waiting for worker to finish...")
|
||||
while self.onedrive_process.poll() is None:
|
||||
|
||||
Reference in New Issue
Block a user