Skip to content

Commit 0f65178

Browse files
authored
Realtime UI updates via WebSocket (#3183)
1 parent a58fc82 commit 0f65178

34 files changed

+1303
-251
lines changed

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ recursive-include changedetectionio/conditions *
55
recursive-include changedetectionio/model *
66
recursive-include changedetectionio/notification *
77
recursive-include changedetectionio/processors *
8+
recursive-include changedetectionio/realtime *
89
recursive-include changedetectionio/static *
910
recursive-include changedetectionio/templates *
1011
recursive-include changedetectionio/tests *

changedetectionio/__init__.py

Lines changed: 92 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,12 @@
77
from changedetectionio.strtobool import strtobool
88
from json.decoder import JSONDecodeError
99
import os
10-
os.environ['EVENTLET_NO_GREENDNS'] = 'yes'
11-
import eventlet
12-
import eventlet.wsgi
1310
import getopt
1411
import platform
1512
import signal
1613
import socket
1714
import sys
15+
from werkzeug.serving import run_simple
1816

1917
from changedetectionio import store
2018
from changedetectionio.flask_app import changedetection_app
@@ -33,8 +31,17 @@ def sigshutdown_handler(_signo, _stack_frame):
3331
logger.critical(f'Shutdown: Got Signal - {name} ({_signo}), Saving DB to disk and calling shutdown')
3432
datastore.sync_to_json()
3533
logger.success('Sync JSON to disk complete.')
36-
# This will throw a SystemExit exception, because eventlet.wsgi.server doesn't know how to deal with it.
37-
# Solution: move to gevent or other server in the future (#2014)
34+
35+
# Shutdown socketio server if available
36+
from changedetectionio.flask_app import socketio_server
37+
if socketio_server and hasattr(socketio_server, 'shutdown'):
38+
try:
39+
logger.info("Shutting down Socket.IO server...")
40+
socketio_server.shutdown()
41+
except Exception as e:
42+
logger.error(f"Error shutting down Socket.IO server: {str(e)}")
43+
44+
# Set flags for clean shutdown
3845
datastore.stop_thread = True
3946
app.config.exit.set()
4047
sys.exit()
@@ -196,13 +203,85 @@ def hide_referrer(response):
196203

197204
s_type = socket.AF_INET6 if ipv6_enabled else socket.AF_INET
198205

199-
if ssl_mode:
200-
# @todo finalise SSL config, but this should get you in the right direction if you need it.
201-
eventlet.wsgi.server(eventlet.wrap_ssl(eventlet.listen((host, port), s_type),
202-
certfile='cert.pem',
203-
keyfile='privkey.pem',
204-
server_side=True), app)
205-
206+
# Get socketio_server from flask_app
207+
from changedetectionio.flask_app import socketio_server
208+
209+
if socketio_server and datastore.data['settings']['application']['ui'].get('open_diff_in_new_tab'):
210+
logger.info("Starting server with Socket.IO support (using threading)...")
211+
212+
# Use Flask-SocketIO's run method with error handling for Werkzeug warning
213+
# This is the cleanest approach that works with all Flask-SocketIO versions
214+
# Use '0.0.0.0' as the default host if none is specified
215+
# This will listen on all available interfaces
216+
listen_host = '0.0.0.0' if host == '' else host
217+
logger.info(f"Using host: {listen_host} and port: {port}")
218+
219+
try:
220+
# First try with the allow_unsafe_werkzeug parameter (newer versions)
221+
if ssl_mode:
222+
socketio_server.run(
223+
app,
224+
host=listen_host,
225+
port=int(port),
226+
certfile='cert.pem',
227+
keyfile='privkey.pem',
228+
debug=False,
229+
use_reloader=False,
230+
allow_unsafe_werkzeug=True # Only in newer versions
231+
)
232+
else:
233+
socketio_server.run(
234+
app,
235+
host=listen_host,
236+
port=int(port),
237+
debug=False,
238+
use_reloader=False,
239+
allow_unsafe_werkzeug=True # Only in newer versions
240+
)
241+
except TypeError:
242+
# If allow_unsafe_werkzeug is not a valid parameter, try without it
243+
logger.info("Falling back to basic run method without allow_unsafe_werkzeug")
244+
# Override the werkzeug safety check by setting an environment variable
245+
os.environ['WERKZEUG_RUN_MAIN'] = 'true'
246+
if ssl_mode:
247+
socketio_server.run(
248+
app,
249+
host=listen_host,
250+
port=int(port),
251+
certfile='cert.pem',
252+
keyfile='privkey.pem',
253+
debug=False,
254+
use_reloader=False
255+
)
256+
else:
257+
socketio_server.run(
258+
app,
259+
host=listen_host,
260+
port=int(port),
261+
debug=False,
262+
use_reloader=False
263+
)
206264
else:
207-
eventlet.wsgi.server(eventlet.listen((host, int(port)), s_type), app)
265+
logger.warning("Socket.IO server not initialized, falling back to standard WSGI server")
266+
# Fallback to standard WSGI server if socketio_server is not available
267+
listen_host = '0.0.0.0' if host == '' else host
268+
if ssl_mode:
269+
# Use Werkzeug's run_simple with SSL support
270+
run_simple(
271+
hostname=listen_host,
272+
port=int(port),
273+
application=app,
274+
use_reloader=False,
275+
use_debugger=False,
276+
ssl_context=('cert.pem', 'privkey.pem')
277+
)
278+
else:
279+
# Use Werkzeug's run_simple for standard HTTP
280+
run_simple(
281+
hostname=listen_host,
282+
port=int(port),
283+
application=app,
284+
use_reloader=False,
285+
use_debugger=False
286+
)
208287

changedetectionio/blueprint/settings/templates/settings.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,10 @@ <h4>Chrome Extension</h4>
246246
{{ render_checkbox_field(form.application.form.ui.form.open_diff_in_new_tab, class="open_diff_in_new_tab") }}
247247
<span class="pure-form-message-inline">Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.</span>
248248
</div>
249+
<div class="pure-control-group">
250+
<span class="pure-form-message-inline">Enable realtime updates in the UI</span>
251+
</div>
252+
249253
</div>
250254
<div class="tab-pane-inner" id="proxies">
251255
<div id="recommended-proxy">

changedetectionio/blueprint/ui/__init__.py

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
from loguru import logger
44
from functools import wraps
55

6+
from changedetectionio.blueprint.ui.ajax import constuct_ui_ajax_blueprint
67
from changedetectionio.store import ChangeDetectionStore
78
from changedetectionio.blueprint.ui.edit import construct_blueprint as construct_edit_blueprint
89
from changedetectionio.blueprint.ui.notification import construct_blueprint as construct_notification_blueprint
910
from changedetectionio.blueprint.ui.views import construct_blueprint as construct_views_blueprint
1011

11-
def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_update_threads, queuedWatchMetaData):
12+
def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_update_threads, queuedWatchMetaData, watch_check_update):
1213
ui_blueprint = Blueprint('ui', __name__, template_folder="templates")
1314

1415
# Register the edit blueprint
@@ -20,9 +21,12 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
2021
ui_blueprint.register_blueprint(notification_blueprint)
2122

2223
# Register the views blueprint
23-
views_blueprint = construct_views_blueprint(datastore, update_q, queuedWatchMetaData)
24+
views_blueprint = construct_views_blueprint(datastore, update_q, queuedWatchMetaData, watch_check_update)
2425
ui_blueprint.register_blueprint(views_blueprint)
25-
26+
27+
ui_ajax_blueprint = constuct_ui_ajax_blueprint(datastore, update_q, running_update_threads, queuedWatchMetaData, watch_check_update)
28+
ui_blueprint.register_blueprint(ui_ajax_blueprint)
29+
2630
# Import the login decorator
2731
from changedetectionio.auth_decorator import login_optionally_required
2832

@@ -35,7 +39,6 @@ def clear_watch_history(uuid):
3539
flash('Watch not found', 'error')
3640
else:
3741
flash("Cleared snapshot history for watch {}".format(uuid))
38-
3942
return redirect(url_for('watchlist.index'))
4043

4144
@ui_blueprint.route("/clear_history", methods=['GET', 'POST'])
@@ -47,7 +50,6 @@ def clear_all_history():
4750
if confirmtext == 'clear':
4851
for uuid in datastore.data['watching'].keys():
4952
datastore.clear_watch_history(uuid)
50-
5153
flash("Cleared snapshot history for all watches")
5254
else:
5355
flash('Incorrect confirmation text.', 'error')
@@ -153,68 +155,59 @@ def form_watch_checknow():
153155
@login_optionally_required
154156
def form_watch_list_checkbox_operations():
155157
op = request.form['op']
156-
uuids = request.form.getlist('uuids')
158+
uuids = [u.strip() for u in request.form.getlist('uuids') if u]
157159

158160
if (op == 'delete'):
159161
for uuid in uuids:
160-
uuid = uuid.strip()
161162
if datastore.data['watching'].get(uuid):
162-
datastore.delete(uuid.strip())
163+
datastore.delete(uuid)
163164
flash("{} watches deleted".format(len(uuids)))
164165

165166
elif (op == 'pause'):
166167
for uuid in uuids:
167-
uuid = uuid.strip()
168168
if datastore.data['watching'].get(uuid):
169-
datastore.data['watching'][uuid.strip()]['paused'] = True
169+
datastore.data['watching'][uuid]['paused'] = True
170170
flash("{} watches paused".format(len(uuids)))
171171

172172
elif (op == 'unpause'):
173173
for uuid in uuids:
174-
uuid = uuid.strip()
175174
if datastore.data['watching'].get(uuid):
176175
datastore.data['watching'][uuid.strip()]['paused'] = False
177176
flash("{} watches unpaused".format(len(uuids)))
178177

179178
elif (op == 'mark-viewed'):
180179
for uuid in uuids:
181-
uuid = uuid.strip()
182180
if datastore.data['watching'].get(uuid):
183181
datastore.set_last_viewed(uuid, int(time.time()))
184182
flash("{} watches updated".format(len(uuids)))
185183

186184
elif (op == 'mute'):
187185
for uuid in uuids:
188-
uuid = uuid.strip()
189186
if datastore.data['watching'].get(uuid):
190-
datastore.data['watching'][uuid.strip()]['notification_muted'] = True
187+
datastore.data['watching'][uuid]['notification_muted'] = True
191188
flash("{} watches muted".format(len(uuids)))
192189

193190
elif (op == 'unmute'):
194191
for uuid in uuids:
195-
uuid = uuid.strip()
196192
if datastore.data['watching'].get(uuid):
197-
datastore.data['watching'][uuid.strip()]['notification_muted'] = False
193+
datastore.data['watching'][uuid]['notification_muted'] = False
198194
flash("{} watches un-muted".format(len(uuids)))
199195

200196
elif (op == 'recheck'):
201197
for uuid in uuids:
202-
uuid = uuid.strip()
203198
if datastore.data['watching'].get(uuid):
204199
# Recheck and require a full reprocessing
205200
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
206201
flash("{} watches queued for rechecking".format(len(uuids)))
207202

208203
elif (op == 'clear-errors'):
209204
for uuid in uuids:
210-
uuid = uuid.strip()
211205
if datastore.data['watching'].get(uuid):
212206
datastore.data['watching'][uuid]["last_error"] = False
213207
flash(f"{len(uuids)} watches errors cleared")
214208

215209
elif (op == 'clear-history'):
216210
for uuid in uuids:
217-
uuid = uuid.strip()
218211
if datastore.data['watching'].get(uuid):
219212
datastore.clear_watch_history(uuid)
220213
flash("{} watches cleared/reset.".format(len(uuids)))
@@ -224,12 +217,11 @@ def form_watch_list_checkbox_operations():
224217
default_notification_format_for_watch
225218
)
226219
for uuid in uuids:
227-
uuid = uuid.strip()
228220
if datastore.data['watching'].get(uuid):
229-
datastore.data['watching'][uuid.strip()]['notification_title'] = None
230-
datastore.data['watching'][uuid.strip()]['notification_body'] = None
231-
datastore.data['watching'][uuid.strip()]['notification_urls'] = []
232-
datastore.data['watching'][uuid.strip()]['notification_format'] = default_notification_format_for_watch
221+
datastore.data['watching'][uuid]['notification_title'] = None
222+
datastore.data['watching'][uuid]['notification_body'] = None
223+
datastore.data['watching'][uuid]['notification_urls'] = []
224+
datastore.data['watching'][uuid]['notification_format'] = default_notification_format_for_watch
233225
flash("{} watches set to use default notification settings".format(len(uuids)))
234226

235227
elif (op == 'assign-tag'):
@@ -238,7 +230,6 @@ def form_watch_list_checkbox_operations():
238230
tag_uuid = datastore.add_tag(title=op_extradata)
239231
if op_extradata and tag_uuid:
240232
for uuid in uuids:
241-
uuid = uuid.strip()
242233
if datastore.data['watching'].get(uuid):
243234
# Bug in old versions caused by bad edit page/tag handler
244235
if isinstance(datastore.data['watching'][uuid]['tags'], str):
@@ -248,6 +239,11 @@ def form_watch_list_checkbox_operations():
248239

249240
flash(f"{len(uuids)} watches were tagged")
250241

242+
if uuids:
243+
for uuid in uuids:
244+
# with app.app_context():
245+
watch_check_update.send(watch_uuid=uuid)
246+
251247
return redirect(url_for('watchlist.index'))
252248

253249

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import time
2+
3+
from blinker import signal
4+
from flask import Blueprint, request, redirect, url_for, flash, render_template, session
5+
6+
7+
from changedetectionio.store import ChangeDetectionStore
8+
9+
def constuct_ui_ajax_blueprint(datastore: ChangeDetectionStore, update_q, running_update_threads, queuedWatchMetaData, watch_check_update):
10+
ui_ajax_blueprint = Blueprint('ajax', __name__, template_folder="templates", url_prefix='/ajax')
11+
12+
# Import the login decorator
13+
from changedetectionio.auth_decorator import login_optionally_required
14+
15+
@ui_ajax_blueprint.route("/toggle", methods=['POST'])
16+
@login_optionally_required
17+
def ajax_toggler():
18+
op = request.values.get('op')
19+
uuid = request.values.get('uuid')
20+
if op and datastore.data['watching'].get(uuid):
21+
if op == 'pause':
22+
datastore.data['watching'][uuid].toggle_pause()
23+
elif op == 'mute':
24+
datastore.data['watching'][uuid].toggle_mute()
25+
elif op == 'recheck':
26+
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
27+
28+
watch_check_update = signal('watch_check_update')
29+
if watch_check_update:
30+
watch_check_update.send(watch_uuid=uuid)
31+
32+
return 'OK'
33+
34+
35+
return ui_ajax_blueprint

changedetectionio/blueprint/ui/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from changedetectionio.auth_decorator import login_optionally_required
99
from changedetectionio import html_tools
1010

11-
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
11+
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData, watch_check_update):
1212
views_blueprint = Blueprint('ui_views', __name__, template_folder="../ui/templates")
1313

1414
@views_blueprint.route("/preview/<string:uuid>", methods=['GET'])

0 commit comments

Comments
 (0)