diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py index 4ef867d2..a123fcd5 100644 --- a/changedetectionio/__init__.py +++ b/changedetectionio/__init__.py @@ -4,11 +4,14 @@ __version__ = '0.49.15' -from changedetectionio.strtobool import strtobool -from json.decoder import JSONDecodeError +# Set environment variables before importing other modules import os os.environ['EVENTLET_NO_GREENDNS'] = 'yes' +# Import eventlet for WSGI server - no monkey patching to avoid conflicts import eventlet + +from changedetectionio.strtobool import strtobool +from json.decoder import JSONDecodeError import eventlet.wsgi import getopt import platform @@ -141,7 +144,27 @@ def main(): logger.critical(str(e)) return + # Get the Flask app app = changedetection_app(app_config, datastore) + + # Now initialize Socket.IO after the app is fully set up + try: + from changedetectionio.realtime.socket_server import ChangeDetectionSocketIO + from changedetectionio.flask_app import socketio_server + import threading + + # Create the Socket.IO server + socketio_server = ChangeDetectionSocketIO(app, datastore) + + # Run the Socket.IO server in a separate thread on port 5005 + socket_thread = threading.Thread(target=socketio_server.run, + kwargs={'host': host, 'port': 5005}) + socket_thread.daemon = True + socket_thread.start() + + logger.info("Socket.IO server initialized successfully on port 5005") + except Exception as e: + logger.warning(f"Failed to initialize Socket.IO server: {str(e)}") signal.signal(signal.SIGTERM, sigshutdown_handler) signal.signal(signal.SIGINT, sigshutdown_handler) @@ -204,5 +227,7 @@ def main(): server_side=True), app) else: + # We'll integrate the Socket.IO server with the WSGI server + # The Socket.IO server is already attached to the Flask app eventlet.wsgi.server(eventlet.listen((host, int(port)), s_type), app) diff --git a/changedetectionio/blueprint/watchlist/templates/watch-overview.html b/changedetectionio/blueprint/watchlist/templates/watch-overview.html index e7896505..c1f76df4 100644 --- a/changedetectionio/blueprint/watchlist/templates/watch-overview.html +++ b/changedetectionio/blueprint/watchlist/templates/watch-overview.html @@ -102,7 +102,7 @@ {% set is_unviewed = watch.newest_history_key| int > watch.last_viewed and watch.history_n>=2 %} {% set checking_now = is_checking_now(watch) %} - + + ChangeDetection.io Socket.IO Server + + +

ChangeDetection.io Socket.IO Server

+

This is the Socket.IO server for ChangeDetection.io real-time updates.

+

Socket.IO endpoint is available at: /socket.io/

+ + + """ + + def start_background_task(self): + """Start the background task if it's not already running""" + with self.thread_lock: + if self.thread is None: + self.thread = threading.Thread(target=self.background_task) + self.thread.daemon = True + self.thread.start() + logger.info("Socket.IO: Started background task thread") + + def handle_connect(self): + """Handle client connection""" + logger.info("Socket.IO: Client connected") + + # Start the background task when the first client connects + self.start_background_task() + + def handle_disconnect(self): + """Handle client disconnection""" + logger.info("Socket.IO: Client disconnected") + + def background_task(self): + """Background task that emits watch status periodically""" + check_interval = 3 # seconds between updates + + try: + with self.main_app.app_context(): + while True: + try: + # Collect all watch data + watches_data = [] + + # Get list of watches that are currently running + from changedetectionio.flask_app import running_update_threads + currently_checking = [] + + # Make a copy to avoid issues if the list changes + threads_snapshot = list(running_update_threads) + for thread in threads_snapshot: + if hasattr(thread, 'current_uuid') and thread.current_uuid: + currently_checking.append(thread.current_uuid) + + # Send all watch data periodically + for uuid, watch in self.datastore.data['watching'].items(): + # Simplified watch data to avoid sending everything + simplified_data = { + 'uuid': uuid, + 'url': watch.get('url', ''), + 'title': watch.get('title', ''), + 'last_checked': watch.get('last_checked', 0), + 'last_changed': watch.get('newest_history_key', 0), + 'history_n': watch.history_n if hasattr(watch, 'history_n') else 0, + 'viewed': watch.get('viewed', True), + 'paused': watch.get('paused', False), + 'checking': uuid in currently_checking + } + watches_data.append(simplified_data) + + # Emit all watch data periodically + self.socketio.emit('watch_data', watches_data) + logger.debug(f"Socket.IO: Emitted watch data for {len(watches_data)} watches") + + except Exception as e: + logger.error(f"Socket.IO error in background task: {str(e)}") + + # Wait before next update + time.sleep(check_interval) + + except Exception as e: + logger.error(f"Socket.IO background task failed: {str(e)}") + + def run(self, host='0.0.0.0', port=5005): + """Run the Socket.IO server on a separate port""" + # Start the background task when the server starts + self.start_background_task() + + # Run the Socket.IO server + # Use 0.0.0.0 to listen on all interfaces + logger.info(f"Starting Socket.IO server on http://{host}:{port}") + self.socketio.run(self.app, host=host, port=port, debug=False, use_reloader=False, allow_unsafe_werkzeug=True) \ No newline at end of file diff --git a/changedetectionio/static/js/edit-columns-resizer.js b/changedetectionio/static/js/edit-columns-resizer.js new file mode 100644 index 00000000..7ca8ceb3 --- /dev/null +++ b/changedetectionio/static/js/edit-columns-resizer.js @@ -0,0 +1,108 @@ +$(document).ready(function() { + // Global variables for resize functionality + let isResizing = false; + let initialX, initialLeftWidth; + + // Setup document-wide mouse move and mouse up handlers + $(document).on('mousemove', handleMouseMove); + $(document).on('mouseup', function() { + isResizing = false; + }); + + // Handle mouse move for resizing + function handleMouseMove(e) { + if (!isResizing) return; + + const $container = $('#filters-and-triggers > div'); + const containerWidth = $container.width(); + const diffX = e.clientX - initialX; + const newLeftWidth = ((initialLeftWidth + diffX) / containerWidth) * 100; + + // Limit the minimum width percentage + if (newLeftWidth > 20 && newLeftWidth < 80) { + $('#edit-text-filter').css('flex', `0 0 ${newLeftWidth}%`); + $('#text-preview').css('flex', `0 0 ${100 - newLeftWidth}%`); + } + } + + // Function to create and setup the resizer + function setupResizer() { + // Only proceed if text-preview is visible + if (!$('#text-preview').is(':visible')) return; + + // Don't add another resizer if one already exists + if ($('#column-resizer').length > 0) return; + + // Create resizer element + const $resizer = $('
', { + class: 'column-resizer', + id: 'column-resizer' + }); + + // Insert before the text preview div + const $container = $('#filters-and-triggers > div'); + if ($container.length) { + $resizer.insertBefore('#text-preview'); + + // Setup mousedown handler for the resizer + $resizer.on('mousedown', function(e) { + isResizing = true; + initialX = e.clientX; + initialLeftWidth = $('#edit-text-filter').width(); + + // Prevent text selection during resize + e.preventDefault(); + }); + } + } + + // Setup resizer when preview is activated + $('#activate-text-preview').on('click', function() { + // Give it a small delay to ensure the preview is visible + setTimeout(setupResizer, 100); + }); + + // Also setup resizer when the filters-and-triggers tab is clicked + $('#filters-and-triggers-tab a').on('click', function() { + // Give it a small delay to ensure everything is loaded + setTimeout(setupResizer, 100); + }); + + // Run the setupResizer function when the page is fully loaded + // to ensure it's added if the text-preview is already visible + setTimeout(setupResizer, 500); + + // Handle window resize events + $(window).on('resize', function() { + // Make sure the resizer is added if text-preview is visible + if ($('#text-preview').is(':visible')) { + setupResizer(); + } + }); + + // Keep checking if the resizer needs to be added + // This ensures it's restored if something removes it + setInterval(function() { + if ($('#text-preview').is(':visible')) { + setupResizer(); + } + }, 500); + + // Add a MutationObserver to watch for DOM changes + // This will help restore the resizer if it gets removed + const observer = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + if (mutation.type === 'childList' && + $('#text-preview').is(':visible') && + $('#column-resizer').length === 0) { + setupResizer(); + } + }); + }); + + // Start observing the container for DOM changes + observer.observe(document.getElementById('filters-and-triggers'), { + childList: true, + subtree: true + }); +}); \ No newline at end of file diff --git a/changedetectionio/static/js/socket.js b/changedetectionio/static/js/socket.js new file mode 100644 index 00000000..350e059a --- /dev/null +++ b/changedetectionio/static/js/socket.js @@ -0,0 +1,78 @@ +// Socket.IO client-side integration for changedetection.io + +$(document).ready(function() { + // Try to create the socket connection to port 5005 - if it fails, the site will still work normally + try { + // Connect to the dedicated Socket.IO server on port 5005 + const socket = io('http://127.0.0.1:5005'); + + // Connection status logging + socket.on('connect', function() { + console.log('Socket.IO connected'); + }); + + socket.on('disconnect', function() { + console.log('Socket.IO disconnected'); + }); + + // Listen for periodically emitted watch data + socket.on('watch_data', function(watches) { + console.log('Received watch data updates'); + + // First, remove checking-now class from all rows + $('.checking-now').removeClass('checking-now'); + + // Update all watches with their current data + watches.forEach(function(watch) { + const $watchRow = $('tr[data-watch-uuid="' + watch.uuid + '"]'); + if ($watchRow.length) { + updateWatchRow($watchRow, watch); + } + }); + }); + + // Function to update a watch row with new data + function updateWatchRow($row, data) { + // Update the last-checked time + const $lastChecked = $row.find('.last-checked'); + if ($lastChecked.length && data.last_checked) { + // Format as timeago if we have the timeago library available + if (typeof timeago !== 'undefined') { + $lastChecked.text(timeago.format(data.last_checked, Date.now()/1000)); + } else { + // Simple fallback if timeago isn't available + const date = new Date(data.last_checked * 1000); + $lastChecked.text(date.toLocaleString()); + } + } + + // Toggle the unviewed class based on viewed status + $row.toggleClass('unviewed', data.viewed === false); + + // If the watch is currently being checked + $row.toggleClass('checking-now', data.checking === true); + + // If a change was detected and not viewed, add highlight effect + if (data.history_n > 0 && data.viewed === false) { + // Don't add the highlight effect too often + if (!$row.hasClass('socket-highlight')) { + $row.addClass('socket-highlight'); + setTimeout(function() { + $row.removeClass('socket-highlight'); + }, 2000); + + console.log('New change detected for:', data.title || data.url); + } + + // Update any change count indicators if present + const $changeCount = $row.find('.change-count'); + if ($changeCount.length) { + $changeCount.text(data.history_n); + } + } + } + } catch (e) { + // If Socket.IO fails to initialize, just log it and continue + console.log('Socket.IO initialization error:', e); + } +}); \ No newline at end of file diff --git a/changedetectionio/static/styles/scss/parts/_socket.scss b/changedetectionio/static/styles/scss/parts/_socket.scss new file mode 100644 index 00000000..b6b472d8 --- /dev/null +++ b/changedetectionio/static/styles/scss/parts/_socket.scss @@ -0,0 +1,30 @@ +// Styles for Socket.IO real-time updates + +@keyframes socket-highlight-flash { + 0% { + background-color: rgba(var(--color-change-highlight-rgb), 0); + } + 50% { + background-color: rgba(var(--color-change-highlight-rgb), 0.4); + } + 100% { + background-color: rgba(var(--color-change-highlight-rgb), 0); + } +} + +.socket-highlight { + animation: socket-highlight-flash 2s ease-in-out; +} + +// Animation for the checking-now state +@keyframes checking-progress { + 0% { background-size: 0% 100%; } + 100% { background-size: 100% 100%; } +} + +tr.checking-now .last-checked { + background-image: linear-gradient(to right, rgba(0, 120, 255, 0.2) 0%, rgba(0, 120, 255, 0.1) 100%); + background-size: 100% 100%; + background-repeat: no-repeat; + animation: checking-progress 10s linear; +} \ No newline at end of file diff --git a/changedetectionio/static/styles/scss/parts/_variables.scss b/changedetectionio/static/styles/scss/parts/_variables.scss index aca4ed55..324552c7 100644 --- a/changedetectionio/static/styles/scss/parts/_variables.scss +++ b/changedetectionio/static/styles/scss/parts/_variables.scss @@ -101,6 +101,7 @@ --color-watch-table-error: var(--color-dark-red); --color-watch-table-row-text: var(--color-grey-100); + --color-change-highlight-rgb: 255, 215, 0; /* Gold color for socket.io highlight */ } html[data-darkmode="true"] { diff --git a/changedetectionio/static/styles/scss/styles.scss b/changedetectionio/static/styles/scss/styles.scss index 21075079..05cbb2b7 100644 --- a/changedetectionio/static/styles/scss/styles.scss +++ b/changedetectionio/static/styles/scss/styles.scss @@ -1070,6 +1070,7 @@ ul { } @import "parts/_visualselector"; +@import "parts/_socket"; #webdriver_delay { width: 5em; diff --git a/changedetectionio/templates/base.html b/changedetectionio/templates/base.html index 36240f6f..0b563d66 100644 --- a/changedetectionio/templates/base.html +++ b/changedetectionio/templates/base.html @@ -31,6 +31,8 @@ + + diff --git a/requirements.txt b/requirements.txt index 83f97720..c6e6d202 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,9 @@ flask_restful flask_cors # For the Chrome extension to operate flask_wtf~=1.2 flask~=2.3 +flask-socketio>=5.5.1 +python-socketio>=5.13.0 +python-engineio>=4.12.0 inscriptis~=2.2 pytz timeago~=1.0