1
0
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:
Boris Pozdena
2026-10-18 10:22:31 +02:00
parent cd6b9ef52a
commit 1dacf5587f
2 changed files with 190 additions and 5 deletions

View File

@@ -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)

View File

@@ -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: