pull/3183/head
dgtlmoon 2025-04-28 15:39:25 +02:00
rodzic 031cb76b7d
commit f53be7c7fb
12 zmienionych plików z 387 dodań i 4 usunięć

Wyświetl plik

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

Wyświetl plik

@ -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) %}
<tr id="{{ watch.uuid }}"
<tr id="{{ watch.uuid }}" data-watch-uuid="{{ watch.uuid }}"
class="{{ loop.cycle('pure-table-odd', 'pure-table-even') }} processor-{{ watch['processor'] }}
{% if watch.last_error is defined and watch.last_error != False %}error{% endif %}
{% if watch.last_notification_error is defined and watch.last_notification_error != False %}error{% endif %}

Wyświetl plik

@ -30,6 +30,7 @@ from flask_restful import abort, Api
from flask_cors import CORS
from flask_wtf import CSRFProtect
from loguru import logger
import eventlet
from changedetectionio import __version__
from changedetectionio import queuedWatchMetaData
@ -54,6 +55,9 @@ app = Flask(__name__,
static_folder="static",
template_folder="templates")
# Will be initialized in changedetection_app
socketio_server = None
# Enable CORS, especially useful for the Chrome extension to operate from anywhere
CORS(app)
@ -215,7 +219,7 @@ class User(flask_login.UserMixin):
def changedetection_app(config=None, datastore_o=None):
logger.trace("TRACE log is enabled")
global datastore
global datastore, socketio_server
datastore = datastore_o
# so far just for read-only via tests, but this will be moved eventually to be the main source
@ -467,6 +471,8 @@ def changedetection_app(config=None, datastore_o=None):
if not os.getenv("GITHUB_REF", False) and not strtobool(os.getenv('DISABLE_VERSION_CHECK', 'no')):
threading.Thread(target=check_for_new_version).start()
# Return the Flask app - the Socket.IO will be attached to it but initialized separately
# This avoids circular dependencies
return app

Wyświetl plik

@ -0,0 +1,3 @@
"""
Socket.IO realtime updates module for changedetection.io
"""

Wyświetl plik

@ -0,0 +1,126 @@
from flask import Flask
from flask_socketio import SocketIO
import threading
import json
import time
from loguru import logger
class ChangeDetectionSocketIO:
def __init__(self, app, datastore):
self.main_app = app
self.datastore = datastore
# Create a separate app for Socket.IO
self.app = Flask(__name__)
# Use threading mode instead of eventlet
self.socketio = SocketIO(self.app,
async_mode='threading',
cors_allowed_origins="*",
logger=False,
engineio_logger=False)
# Set up event handlers
self.socketio.on_event('connect', self.handle_connect)
self.socketio.on_event('disconnect', self.handle_disconnect)
# Don't patch the update_watch method - this was causing issues
# Just start a background thread to periodically emit watch status
self.thread = None
self.thread_lock = threading.Lock()
# Set up a simple index route for the Socket.IO app
@self.app.route('/')
def index():
return """
<html>
<head>
<title>ChangeDetection.io Socket.IO Server</title>
</head>
<body>
<h1>ChangeDetection.io Socket.IO Server</h1>
<p>This is the Socket.IO server for ChangeDetection.io real-time updates.</p>
<p>Socket.IO endpoint is available at: <code>/socket.io/</code></p>
</body>
</html>
"""
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)

Wyświetl plik

@ -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 = $('<div>', {
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
});
});

Wyświetl plik

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

Wyświetl plik

@ -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;
}

Wyświetl plik

@ -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"] {

Wyświetl plik

@ -1070,6 +1070,7 @@ ul {
}
@import "parts/_visualselector";
@import "parts/_socket";
#webdriver_delay {
width: 5em;

Wyświetl plik

@ -31,6 +31,8 @@
</script>
<script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script>
<script src="{{url_for('static_content', group='js', filename='csrf.js')}}" defer></script>
<script src="https://cdn.socket.io/4.6.0/socket.io.min.js" integrity="sha384-c79GN5VsunZvi+Q/WObgk2in0CbZsHnjEqvFxC5DxHn9lTfNce2WW6h2pH6u/kF+" crossorigin="anonymous"></script>
<script src="{{url_for('static_content', group='js', filename='socket.js')}}" defer></script>
</head>
<body class="">

Wyświetl plik

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