From 0f651781906a0b9d9bae24f3e773fc881cfb62b2 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Mon, 26 May 2025 20:12:32 +0200 Subject: [PATCH] Realtime UI updates via WebSocket (#3183) --- MANIFEST.in | 1 + changedetectionio/__init__.py | 103 ++++++- .../settings/templates/settings.html | 4 + changedetectionio/blueprint/ui/__init__.py | 46 ++- changedetectionio/blueprint/ui/ajax.py | 35 +++ changedetectionio/blueprint/ui/views.py | 2 +- .../blueprint/watchlist/__init__.py | 44 +-- .../watchlist/templates/watch-overview.html | 84 ++---- changedetectionio/custom_queue.py | 52 ++++ changedetectionio/flask_app.py | 118 +++++--- changedetectionio/model/Watch.py | 53 ++++ changedetectionio/model/__init__.py | 1 + changedetectionio/notification/handler.py | 2 - changedetectionio/realtime/__init__.py | 3 + changedetectionio/realtime/socket_server.py | 283 ++++++++++++++++++ changedetectionio/static/js/realtime.js | 115 +++++++ changedetectionio/static/js/socket.io.min.js | 7 + changedetectionio/static/js/watch-overview.js | 4 +- .../static/styles/scss/parts/_socket.scss | 31 ++ .../styles/scss/parts/_watch_table.scss | 118 ++++++++ .../static/styles/scss/styles.scss | 52 +--- changedetectionio/static/styles/styles.css | 105 +++++-- changedetectionio/store.py | 5 + changedetectionio/templates/base.html | 7 + .../tests/realtime/test_socketio.py | 72 +++++ changedetectionio/tests/test_auth.py | 10 +- changedetectionio/tests/test_backend.py | 6 +- .../tests/test_basic_socketio.py | 139 +++++++++ .../tests/test_ignorehyperlinks.py | 11 +- .../tests/test_nonrenderable_pages.py | 2 +- changedetectionio/tests/test_rss.py | 9 + changedetectionio/tests/util.py | 5 +- changedetectionio/update_worker.py | 16 +- requirements.txt | 11 + 34 files changed, 1304 insertions(+), 252 deletions(-) create mode 100644 changedetectionio/blueprint/ui/ajax.py create mode 100644 changedetectionio/custom_queue.py create mode 100644 changedetectionio/realtime/__init__.py create mode 100644 changedetectionio/realtime/socket_server.py create mode 100644 changedetectionio/static/js/realtime.js create mode 100644 changedetectionio/static/js/socket.io.min.js create mode 100644 changedetectionio/static/styles/scss/parts/_socket.scss create mode 100644 changedetectionio/static/styles/scss/parts/_watch_table.scss create mode 100755 changedetectionio/tests/realtime/test_socketio.py create mode 100644 changedetectionio/tests/test_basic_socketio.py diff --git a/MANIFEST.in b/MANIFEST.in index 88601b4b..950c182d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,6 +5,7 @@ recursive-include changedetectionio/conditions * recursive-include changedetectionio/model * recursive-include changedetectionio/notification * recursive-include changedetectionio/processors * +recursive-include changedetectionio/realtime * recursive-include changedetectionio/static * recursive-include changedetectionio/templates * recursive-include changedetectionio/tests * diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py index e1f22dca..ffe7a2cd 100644 --- a/changedetectionio/__init__.py +++ b/changedetectionio/__init__.py @@ -7,14 +7,12 @@ __version__ = '0.49.17' from changedetectionio.strtobool import strtobool from json.decoder import JSONDecodeError import os -os.environ['EVENTLET_NO_GREENDNS'] = 'yes' -import eventlet -import eventlet.wsgi import getopt import platform import signal import socket import sys +from werkzeug.serving import run_simple from changedetectionio import store from changedetectionio.flask_app import changedetection_app @@ -33,8 +31,17 @@ def sigshutdown_handler(_signo, _stack_frame): logger.critical(f'Shutdown: Got Signal - {name} ({_signo}), Saving DB to disk and calling shutdown') datastore.sync_to_json() logger.success('Sync JSON to disk complete.') - # This will throw a SystemExit exception, because eventlet.wsgi.server doesn't know how to deal with it. - # Solution: move to gevent or other server in the future (#2014) + + # Shutdown socketio server if available + from changedetectionio.flask_app import socketio_server + if socketio_server and hasattr(socketio_server, 'shutdown'): + try: + logger.info("Shutting down Socket.IO server...") + socketio_server.shutdown() + except Exception as e: + logger.error(f"Error shutting down Socket.IO server: {str(e)}") + + # Set flags for clean shutdown datastore.stop_thread = True app.config.exit.set() sys.exit() @@ -196,13 +203,85 @@ def main(): s_type = socket.AF_INET6 if ipv6_enabled else socket.AF_INET - if ssl_mode: - # @todo finalise SSL config, but this should get you in the right direction if you need it. - eventlet.wsgi.server(eventlet.wrap_ssl(eventlet.listen((host, port), s_type), - certfile='cert.pem', - keyfile='privkey.pem', - server_side=True), app) + # Get socketio_server from flask_app + from changedetectionio.flask_app import socketio_server + if socketio_server and datastore.data['settings']['application']['ui'].get('open_diff_in_new_tab'): + logger.info("Starting server with Socket.IO support (using threading)...") + + # Use Flask-SocketIO's run method with error handling for Werkzeug warning + # This is the cleanest approach that works with all Flask-SocketIO versions + # Use '0.0.0.0' as the default host if none is specified + # This will listen on all available interfaces + listen_host = '0.0.0.0' if host == '' else host + logger.info(f"Using host: {listen_host} and port: {port}") + + try: + # First try with the allow_unsafe_werkzeug parameter (newer versions) + if ssl_mode: + socketio_server.run( + app, + host=listen_host, + port=int(port), + certfile='cert.pem', + keyfile='privkey.pem', + debug=False, + use_reloader=False, + allow_unsafe_werkzeug=True # Only in newer versions + ) + else: + socketio_server.run( + app, + host=listen_host, + port=int(port), + debug=False, + use_reloader=False, + allow_unsafe_werkzeug=True # Only in newer versions + ) + except TypeError: + # If allow_unsafe_werkzeug is not a valid parameter, try without it + logger.info("Falling back to basic run method without allow_unsafe_werkzeug") + # Override the werkzeug safety check by setting an environment variable + os.environ['WERKZEUG_RUN_MAIN'] = 'true' + if ssl_mode: + socketio_server.run( + app, + host=listen_host, + port=int(port), + certfile='cert.pem', + keyfile='privkey.pem', + debug=False, + use_reloader=False + ) + else: + socketio_server.run( + app, + host=listen_host, + port=int(port), + debug=False, + use_reloader=False + ) else: - eventlet.wsgi.server(eventlet.listen((host, int(port)), s_type), app) + logger.warning("Socket.IO server not initialized, falling back to standard WSGI server") + # Fallback to standard WSGI server if socketio_server is not available + listen_host = '0.0.0.0' if host == '' else host + if ssl_mode: + # Use Werkzeug's run_simple with SSL support + run_simple( + hostname=listen_host, + port=int(port), + application=app, + use_reloader=False, + use_debugger=False, + ssl_context=('cert.pem', 'privkey.pem') + ) + else: + # Use Werkzeug's run_simple for standard HTTP + run_simple( + hostname=listen_host, + port=int(port), + application=app, + use_reloader=False, + use_debugger=False + ) diff --git a/changedetectionio/blueprint/settings/templates/settings.html b/changedetectionio/blueprint/settings/templates/settings.html index c45b267e..5f302331 100644 --- a/changedetectionio/blueprint/settings/templates/settings.html +++ b/changedetectionio/blueprint/settings/templates/settings.html @@ -246,6 +246,10 @@ nav {{ render_checkbox_field(form.application.form.ui.form.open_diff_in_new_tab, class="open_diff_in_new_tab") }} Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab. +
+ Enable realtime updates in the UI +
+