mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-01-19 05:30:24 +00:00
Compare commits
7 Commits
clear-hist
...
mute-pause
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1eadc8e398 | ||
|
|
f9f7f103f0 | ||
|
|
5e5674f48d | ||
|
|
272e68ad2e | ||
|
|
01e06979d8 | ||
|
|
e45c77d51d | ||
|
|
bee1130c6e |
@@ -84,6 +84,7 @@ jobs:
|
||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_watch_model'
|
||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_jinja2_security'
|
||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_semver'
|
||||
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_html_to_text'
|
||||
|
||||
# Basic pytest tests with ancillary services
|
||||
basic-tests:
|
||||
|
||||
@@ -287,7 +287,9 @@ def main():
|
||||
return dict(right_sticky="v{}".format(datastore.data['version_tag']),
|
||||
new_version_available=app.config['NEW_VERSION_AVAILABLE'],
|
||||
has_password=datastore.data['settings']['application']['password'] != False,
|
||||
socket_io_enabled=datastore.data['settings']['application']['ui'].get('socket_io_enabled', True)
|
||||
socket_io_enabled=datastore.data['settings']['application']['ui'].get('socket_io_enabled', True),
|
||||
all_paused=datastore.data['settings']['application'].get('all_paused', False),
|
||||
all_muted=datastore.data['settings']['application'].get('all_muted', False)
|
||||
)
|
||||
|
||||
# Monitored websites will not receive a Referer header when a user clicks on an outgoing link.
|
||||
|
||||
@@ -2,7 +2,9 @@ from changedetectionio import queuedWatchMetaData
|
||||
from changedetectionio import worker_handler
|
||||
from flask_expects_json import expects_json
|
||||
from flask_restful import abort, Resource
|
||||
from loguru import logger
|
||||
|
||||
import threading
|
||||
from flask import request
|
||||
from . import auth
|
||||
|
||||
@@ -28,18 +30,36 @@ class Tag(Resource):
|
||||
abort(404, message=f'No tag exists with the UUID of {uuid}')
|
||||
|
||||
if request.args.get('recheck'):
|
||||
# Recheck all, including muted
|
||||
# Get most overdue first
|
||||
i=0
|
||||
# Recheck all watches with this tag, including muted
|
||||
# First collect watches to queue
|
||||
watches_to_queue = []
|
||||
for k in sorted(self.datastore.data['watching'].items(), key=lambda item: item[1].get('last_checked', 0)):
|
||||
watch_uuid = k[0]
|
||||
watch = k[1]
|
||||
if not watch['paused'] and tag['uuid'] not in watch['tags']:
|
||||
continue
|
||||
worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}))
|
||||
i+=1
|
||||
if not watch['paused'] and tag['uuid'] in watch['tags']:
|
||||
watches_to_queue.append(watch_uuid)
|
||||
|
||||
return f"OK, {i} watches queued", 200
|
||||
# If less than 20 watches, queue synchronously for immediate feedback
|
||||
if len(watches_to_queue) < 20:
|
||||
for watch_uuid in watches_to_queue:
|
||||
worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}))
|
||||
return {'status': f'OK, queued {len(watches_to_queue)} watches for rechecking'}, 200
|
||||
else:
|
||||
# 20+ watches - queue in background thread to avoid blocking API response
|
||||
def queue_watches_background():
|
||||
"""Background thread to queue watches - discarded after completion."""
|
||||
try:
|
||||
for watch_uuid in watches_to_queue:
|
||||
worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}))
|
||||
logger.info(f"Background queueing complete for tag {tag['uuid']}: {len(watches_to_queue)} watches queued")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in background queueing for tag {tag['uuid']}: {e}")
|
||||
|
||||
# Start background thread and return immediately
|
||||
thread = threading.Thread(target=queue_watches_background, daemon=True, name=f"QueueTag-{tag['uuid'][:8]}")
|
||||
thread.start()
|
||||
|
||||
return {'status': f'OK, queueing {len(watches_to_queue)} watches in background'}, 202
|
||||
|
||||
if request.args.get('muted', '') == 'muted':
|
||||
self.datastore.data['settings']['application']['tags'][uuid]['notification_muted'] = True
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import threading
|
||||
|
||||
from changedetectionio.validate_url import is_safe_valid_url
|
||||
|
||||
@@ -469,8 +470,29 @@ class CreateWatch(Resource):
|
||||
}
|
||||
|
||||
if request.args.get('recheck_all'):
|
||||
for uuid in self.datastore.data['watching'].keys():
|
||||
worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
||||
return {'status': "OK"}, 200
|
||||
# Collect all watches to queue
|
||||
watches_to_queue = self.datastore.data['watching'].keys()
|
||||
|
||||
# If less than 20 watches, queue synchronously for immediate feedback
|
||||
if len(watches_to_queue) < 20:
|
||||
for uuid in watches_to_queue:
|
||||
worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
||||
return {'status': f'OK, queued {len(watches_to_queue)} watches for rechecking'}, 200
|
||||
else:
|
||||
# 20+ watches - queue in background thread to avoid blocking API response
|
||||
def queue_all_watches_background():
|
||||
"""Background thread to queue all watches - discarded after completion."""
|
||||
try:
|
||||
for uuid in watches_to_queue:
|
||||
worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
||||
logger.info(f"Background queueing complete: {len(watches_to_queue)} watches queued")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in background queueing all watches: {e}")
|
||||
|
||||
# Start background thread and return immediately
|
||||
thread = threading.Thread(target=queue_all_watches_background, daemon=True, name="QueueAllWatches-Background")
|
||||
thread.start()
|
||||
|
||||
return {'status': f'OK, queueing {len(watches_to_queue)} watches in background'}, 202
|
||||
|
||||
return list, 200
|
||||
@@ -75,7 +75,6 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
|
||||
continue
|
||||
|
||||
uuid = queued_item_data.item.get('uuid')
|
||||
|
||||
# RACE CONDITION FIX: Check if this UUID is already being processed by another worker
|
||||
from changedetectionio import worker_handler
|
||||
from changedetectionio.queuedWatchMetaData import PrioritizedItem
|
||||
@@ -133,8 +132,14 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
|
||||
# All fetchers are now async, so call directly
|
||||
await update_handler.call_browser()
|
||||
|
||||
# Run change detection (this is synchronous)
|
||||
changed_detected, update_obj, contents = update_handler.run_changedetection(watch=watch)
|
||||
# Run change detection in executor to avoid blocking event loop
|
||||
# This includes CPU-intensive operations like HTML parsing (lxml/inscriptis)
|
||||
# which can take 2-10ms and cause GIL contention across workers
|
||||
loop = asyncio.get_event_loop()
|
||||
changed_detected, update_obj, contents = await loop.run_in_executor(
|
||||
executor,
|
||||
lambda: update_handler.run_changedetection(watch=watch)
|
||||
)
|
||||
|
||||
except PermissionError as e:
|
||||
logger.critical(f"File permission error updating file, watch: {uuid}")
|
||||
|
||||
@@ -78,14 +78,20 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
# Handle dynamic worker count adjustment
|
||||
old_worker_count = datastore.data['settings']['requests'].get('workers', 1)
|
||||
new_worker_count = form.data['requests'].get('workers', 1)
|
||||
|
||||
|
||||
datastore.data['settings']['requests'].update(form.data['requests'])
|
||||
|
||||
|
||||
# Adjust worker count if it changed
|
||||
if new_worker_count != old_worker_count:
|
||||
from changedetectionio import worker_handler
|
||||
from changedetectionio.flask_app import update_q, notification_q, app, datastore as ds
|
||||
|
||||
|
||||
# Check CPU core availability and warn if worker count is high
|
||||
cpu_count = os.cpu_count()
|
||||
if cpu_count and new_worker_count >= (cpu_count * 0.9):
|
||||
flash(gettext("Warning: Worker count ({}) is close to or exceeds available CPU cores ({})").format(
|
||||
new_worker_count, cpu_count), 'warning')
|
||||
|
||||
result = worker_handler.adjust_async_worker_count(
|
||||
new_count=new_worker_count,
|
||||
update_q=update_q,
|
||||
@@ -93,7 +99,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
app=app,
|
||||
datastore=ds
|
||||
)
|
||||
|
||||
|
||||
if result['status'] == 'success':
|
||||
flash(gettext("Worker count adjusted: {}").format(result['message']), 'notice')
|
||||
elif result['status'] == 'not_supported':
|
||||
@@ -187,4 +193,32 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
logs=notification_debug_log if len(notification_debug_log) else ["Notification logs are empty - no notifications sent yet."])
|
||||
return output
|
||||
|
||||
@settings_blueprint.route("/toggle-all-paused", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def toggle_all_paused():
|
||||
current_state = datastore.data['settings']['application'].get('all_paused', False)
|
||||
datastore.data['settings']['application']['all_paused'] = not current_state
|
||||
datastore.needs_write_urgent = True
|
||||
|
||||
if datastore.data['settings']['application']['all_paused']:
|
||||
flash(gettext("Automatic scheduling paused - checks will not be queued."), 'notice')
|
||||
else:
|
||||
flash(gettext("Automatic scheduling resumed - checks will be queued normally."), 'notice')
|
||||
|
||||
return redirect(url_for('watchlist.index'))
|
||||
|
||||
@settings_blueprint.route("/toggle-all-muted", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def toggle_all_muted():
|
||||
current_state = datastore.data['settings']['application'].get('all_muted', False)
|
||||
datastore.data['settings']['application']['all_muted'] = not current_state
|
||||
datastore.needs_write_urgent = True
|
||||
|
||||
if datastore.data['settings']['application']['all_muted']:
|
||||
flash(gettext("All notifications muted."), 'notice')
|
||||
else:
|
||||
flash(gettext("All notifications unmuted."), 'notice')
|
||||
|
||||
return redirect(url_for('watchlist.index'))
|
||||
|
||||
return settings_blueprint
|
||||
@@ -25,6 +25,9 @@
|
||||
<li class="tab"><a href="#ui-options">{{ _('UI Options') }}</a></li>
|
||||
<li class="tab"><a href="#api">{{ _('API') }}</a></li>
|
||||
<li class="tab"><a href="#rss">{{ _('RSS') }}</a></li>
|
||||
<li class="pure-menu-item menu-collapsible {% if request.endpoint.startswith('backups.') %}active{% endif %}">
|
||||
<a href="{{ url_for('backups.index') }}" class="pure-menu-link">{{ _('Backups') }}</a>
|
||||
</li>
|
||||
<li class="tab"><a href="#timedate">{{ _('Time & Date') }}</a></li>
|
||||
<li class="tab"><a href="#proxies">{{ _('CAPTCHA & Proxies') }}</a></li>
|
||||
{% if plugin_tabs %}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import threading
|
||||
from flask import Blueprint, request, render_template, flash, url_for, redirect
|
||||
from flask_babel import gettext
|
||||
from loguru import logger
|
||||
|
||||
from changedetectionio.store import ChangeDetectionStore
|
||||
from changedetectionio.flask_app import login_optionally_required
|
||||
@@ -62,39 +64,73 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
@tags_blueprint.route("/delete/<string:uuid>", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def delete(uuid):
|
||||
removed = 0
|
||||
# Delete the tag, and any tag reference
|
||||
# Delete the tag from settings immediately
|
||||
if datastore.data['settings']['application']['tags'].get(uuid):
|
||||
del datastore.data['settings']['application']['tags'][uuid]
|
||||
|
||||
for watch_uuid, watch in datastore.data['watching'].items():
|
||||
if watch.get('tags') and uuid in watch['tags']:
|
||||
removed += 1
|
||||
watch['tags'].remove(uuid)
|
||||
# Remove tag from all watches in background thread to avoid blocking
|
||||
def remove_tag_background(tag_uuid):
|
||||
"""Background thread to remove tag from watches - discarded after completion."""
|
||||
removed_count = 0
|
||||
try:
|
||||
for watch_uuid, watch in datastore.data['watching'].items():
|
||||
if watch.get('tags') and tag_uuid in watch['tags']:
|
||||
watch['tags'].remove(tag_uuid)
|
||||
removed_count += 1
|
||||
logger.info(f"Background: Tag {tag_uuid} removed from {removed_count} watches")
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing tag from watches: {e}")
|
||||
|
||||
flash(gettext("Tag deleted and removed from {} watches").format(removed))
|
||||
# Start daemon thread
|
||||
threading.Thread(target=remove_tag_background, args=(uuid,), daemon=True).start()
|
||||
|
||||
flash(gettext("Tag deleted, removing from watches in background"))
|
||||
return redirect(url_for('tags.tags_overview_page'))
|
||||
|
||||
@tags_blueprint.route("/unlink/<string:uuid>", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def unlink(uuid):
|
||||
unlinked = 0
|
||||
for watch_uuid, watch in datastore.data['watching'].items():
|
||||
if watch.get('tags') and uuid in watch['tags']:
|
||||
unlinked += 1
|
||||
watch['tags'].remove(uuid)
|
||||
# Unlink tag from all watches in background thread to avoid blocking
|
||||
def unlink_tag_background(tag_uuid):
|
||||
"""Background thread to unlink tag from watches - discarded after completion."""
|
||||
unlinked_count = 0
|
||||
try:
|
||||
for watch_uuid, watch in datastore.data['watching'].items():
|
||||
if watch.get('tags') and tag_uuid in watch['tags']:
|
||||
watch['tags'].remove(tag_uuid)
|
||||
unlinked_count += 1
|
||||
logger.info(f"Background: Tag {tag_uuid} unlinked from {unlinked_count} watches")
|
||||
except Exception as e:
|
||||
logger.error(f"Error unlinking tag from watches: {e}")
|
||||
|
||||
flash(gettext("Tag unlinked removed from {} watches").format(unlinked))
|
||||
# Start daemon thread
|
||||
threading.Thread(target=unlink_tag_background, args=(uuid,), daemon=True).start()
|
||||
|
||||
flash(gettext("Unlinking tag from watches in background"))
|
||||
return redirect(url_for('tags.tags_overview_page'))
|
||||
|
||||
@tags_blueprint.route("/delete_all", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def delete_all():
|
||||
for watch_uuid, watch in datastore.data['watching'].items():
|
||||
watch['tags'] = []
|
||||
# Clear all tags from settings immediately
|
||||
datastore.data['settings']['application']['tags'] = {}
|
||||
|
||||
flash(gettext("All tags deleted"))
|
||||
# Clear tags from all watches in background thread to avoid blocking
|
||||
def clear_all_tags_background():
|
||||
"""Background thread to clear tags from all watches - discarded after completion."""
|
||||
cleared_count = 0
|
||||
try:
|
||||
for watch_uuid, watch in datastore.data['watching'].items():
|
||||
watch['tags'] = []
|
||||
cleared_count += 1
|
||||
logger.info(f"Background: Cleared tags from {cleared_count} watches")
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing tags from watches: {e}")
|
||||
|
||||
# Start daemon thread
|
||||
threading.Thread(target=clear_all_tags_background, daemon=True).start()
|
||||
|
||||
flash(gettext("All tags deleted, clearing from watches in background"))
|
||||
return redirect(url_for('tags.tags_overview_page'))
|
||||
|
||||
@tags_blueprint.route("/edit/<string:uuid>", methods=['GET'])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import time
|
||||
import threading
|
||||
from flask import Blueprint, request, redirect, url_for, flash, render_template, session
|
||||
from flask_babel import gettext
|
||||
from loguru import logger
|
||||
@@ -151,9 +152,24 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
|
||||
confirmtext = request.form.get('confirmtext')
|
||||
|
||||
if confirmtext == 'clear':
|
||||
for uuid in datastore.data['watching'].keys():
|
||||
datastore.clear_watch_history(uuid)
|
||||
flash(gettext("Cleared snapshot history for all watches"))
|
||||
# Run in background thread to avoid blocking
|
||||
def clear_history_background():
|
||||
# Capture UUIDs first to avoid race conditions
|
||||
watch_uuids = list(datastore.data['watching'].keys())
|
||||
logger.info(f"Background: Clearing history for {len(watch_uuids)} watches")
|
||||
|
||||
for uuid in watch_uuids:
|
||||
try:
|
||||
datastore.clear_watch_history(uuid)
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing history for watch {uuid}: {e}")
|
||||
|
||||
logger.info("Background: Completed clearing history")
|
||||
|
||||
# Start daemon thread
|
||||
threading.Thread(target=clear_history_background, daemon=True).start()
|
||||
|
||||
flash(gettext("History clearing started in background"))
|
||||
else:
|
||||
flash(gettext('Incorrect confirmation text.'), 'error')
|
||||
|
||||
@@ -169,18 +185,32 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
|
||||
# Save the current newest history as the most recently viewed
|
||||
with_errors = request.args.get('with_errors') == "1"
|
||||
tag_limit = request.args.get('tag')
|
||||
logger.debug(f"Limiting to tag {tag_limit}")
|
||||
now = int(time.time())
|
||||
for watch_uuid, watch in datastore.data['watching'].items():
|
||||
if with_errors and not watch.get('last_error'):
|
||||
continue
|
||||
|
||||
if tag_limit and ( not watch.get('tags') or tag_limit not in watch['tags'] ):
|
||||
logger.debug(f"Skipping watch {watch_uuid}")
|
||||
continue
|
||||
# Mark watches as viewed in background thread to avoid blocking
|
||||
def mark_viewed_background():
|
||||
"""Background thread to mark watches as viewed - discarded after completion."""
|
||||
marked_count = 0
|
||||
try:
|
||||
for watch_uuid, watch in datastore.data['watching'].items():
|
||||
if with_errors and not watch.get('last_error'):
|
||||
continue
|
||||
|
||||
datastore.set_last_viewed(watch_uuid, now)
|
||||
if tag_limit and (not watch.get('tags') or tag_limit not in watch['tags']):
|
||||
continue
|
||||
|
||||
datastore.set_last_viewed(watch_uuid, now)
|
||||
marked_count += 1
|
||||
|
||||
logger.info(f"Background marking complete: {marked_count} watches marked as viewed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in background mark as viewed: {e}")
|
||||
|
||||
# Start background thread and return immediately
|
||||
thread = threading.Thread(target=mark_viewed_background, daemon=True)
|
||||
thread.start()
|
||||
|
||||
flash(gettext("Marking watches as viewed in background..."))
|
||||
return redirect(url_for('watchlist.index', tag=tag_limit))
|
||||
|
||||
@ui_blueprint.route("/delete", methods=['GET'])
|
||||
@@ -225,35 +255,49 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
|
||||
uuid = request.args.get('uuid')
|
||||
with_errors = request.args.get('with_errors') == "1"
|
||||
|
||||
i = 0
|
||||
|
||||
if uuid:
|
||||
# Single watch - queue immediately
|
||||
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
||||
i += 1
|
||||
|
||||
flash(gettext("Queued 1 watch for rechecking."))
|
||||
else:
|
||||
# Recheck all, including muted
|
||||
# Get most overdue first
|
||||
# Multiple watches - first count how many need to be queued
|
||||
watches_to_queue = []
|
||||
for k in sorted(datastore.data['watching'].items(), key=lambda item: item[1].get('last_checked', 0)):
|
||||
watch_uuid = k[0]
|
||||
watch = k[1]
|
||||
if not watch['paused']:
|
||||
if watch_uuid:
|
||||
if with_errors and not watch.get('last_error'):
|
||||
continue
|
||||
if not watch['paused'] and watch_uuid:
|
||||
if with_errors and not watch.get('last_error'):
|
||||
continue
|
||||
if tag != None and tag not in watch['tags']:
|
||||
continue
|
||||
watches_to_queue.append(watch_uuid)
|
||||
|
||||
if tag != None and tag not in watch['tags']:
|
||||
continue
|
||||
# If less than 20 watches, queue synchronously for immediate feedback
|
||||
if len(watches_to_queue) < 20:
|
||||
for watch_uuid in watches_to_queue:
|
||||
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}))
|
||||
|
||||
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}))
|
||||
i += 1
|
||||
if len(watches_to_queue) == 1:
|
||||
flash(gettext("Queued 1 watch for rechecking."))
|
||||
else:
|
||||
flash(gettext("Queued {} watches for rechecking.").format(len(watches_to_queue)))
|
||||
else:
|
||||
# 20+ watches - queue in background thread to avoid blocking HTTP response
|
||||
def queue_watches_background():
|
||||
"""Background thread to queue watches - discarded after completion."""
|
||||
try:
|
||||
for watch_uuid in watches_to_queue:
|
||||
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': watch_uuid}))
|
||||
logger.info(f"Background queueing complete: {len(watches_to_queue)} watches queued")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in background queueing: {e}")
|
||||
|
||||
if i == 1:
|
||||
flash(gettext("Queued 1 watch for rechecking."))
|
||||
if i > 1:
|
||||
flash(gettext("Queued {} watches for rechecking.").format(i))
|
||||
if i == 0:
|
||||
flash(gettext("No watches available to recheck."))
|
||||
# Start background thread and return immediately
|
||||
thread = threading.Thread(target=queue_watches_background, daemon=True, name="QueueWatches-Background")
|
||||
thread.start()
|
||||
|
||||
# Return immediately with approximate message
|
||||
flash(gettext("Queueing watches for rechecking in background..."))
|
||||
|
||||
return redirect(url_for('watchlist.index', **({'tag': tag} if tag else {})))
|
||||
|
||||
|
||||
@@ -374,6 +374,9 @@ def changedetection_app(config=None, datastore_o=None):
|
||||
global datastore, socketio_server
|
||||
datastore = datastore_o
|
||||
|
||||
# Set datastore reference in notification queue for all_muted checking
|
||||
notification_q.set_datastore(datastore)
|
||||
|
||||
# Import and create a wrapper for is_safe_url that has access to app
|
||||
from changedetectionio.is_safe_url import is_safe_url as _is_safe_url
|
||||
|
||||
@@ -999,9 +1002,14 @@ def ticker_thread_check_time_launch_checks():
|
||||
|
||||
if health_result['status'] != 'healthy':
|
||||
logger.warning(f"Worker health check: {health_result['message']}")
|
||||
|
||||
|
||||
last_health_check = now
|
||||
|
||||
# Check if all checks are paused
|
||||
if datastore.data['settings']['application'].get('all_paused', False):
|
||||
app.config.exit.wait(1)
|
||||
continue
|
||||
|
||||
# Get a list of watches by UUID that are currently fetching data
|
||||
running_uuids = worker_handler.get_running_uuids()
|
||||
|
||||
|
||||
@@ -539,6 +539,18 @@ def cdata_in_document_to_text(html_content: str, render_anchor_tag_content=False
|
||||
|
||||
|
||||
def html_to_text(html_content: str, render_anchor_tag_content=False, is_rss=False, timeout=10) -> str:
|
||||
"""
|
||||
Convert HTML content to plain text using inscriptis.
|
||||
|
||||
Thread-Safety: This function uses inscriptis.get_text() which internally calls
|
||||
lxml.html.fromstring() with the default parser. Testing with 50 concurrent threads
|
||||
confirms this approach is thread-safe and produces deterministic output.
|
||||
|
||||
Alternative Approach Rejected: An explicit HTMLParser instance (thread-local or fresh)
|
||||
would also be thread-safe, but was found to break change detection logic in subtle ways
|
||||
(test_check_basic_change_detection_functionality). The default parser provides correct
|
||||
and reliable behavior.
|
||||
"""
|
||||
from inscriptis import get_text
|
||||
from inscriptis.model.config import ParserConfig
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ class model(dict):
|
||||
},
|
||||
'application': {
|
||||
# Custom notification content
|
||||
'all_paused': False,
|
||||
'all_muted': False,
|
||||
'api_access_token_enabled': True,
|
||||
'base_url' : None,
|
||||
'empty_pages_are_a_change': False,
|
||||
|
||||
@@ -130,7 +130,7 @@ def get_asset(asset_name, watch, datastore, request):
|
||||
except Exception as e:
|
||||
exception_container[0] = e
|
||||
|
||||
thread = threading.Thread(target=thread_target)
|
||||
thread = threading.Thread(target=thread_target, daemon=True, name="ImageDiff-Asset")
|
||||
thread.start()
|
||||
thread.join(timeout=60)
|
||||
|
||||
@@ -284,7 +284,7 @@ def _draw_bounding_box_if_configured(img_bytes, watch, datastore):
|
||||
except Exception as e:
|
||||
exception_container[0] = e
|
||||
|
||||
thread = threading.Thread(target=thread_target)
|
||||
thread = threading.Thread(target=thread_target, daemon=True, name="ImageDiff-BoundingBox")
|
||||
thread.start()
|
||||
thread.join(timeout=15)
|
||||
|
||||
@@ -393,7 +393,7 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect)
|
||||
except Exception as e:
|
||||
exception_container[0] = e
|
||||
|
||||
thread = threading.Thread(target=thread_target)
|
||||
thread = threading.Thread(target=thread_target, daemon=True, name="ImageDiff-ChangePercentage")
|
||||
thread.start()
|
||||
thread.join(timeout=60)
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@ class perform_site_check(difference_detection_processor):
|
||||
except Exception as e:
|
||||
exception_container[0] = e
|
||||
|
||||
thread = threading.Thread(target=thread_target)
|
||||
thread = threading.Thread(target=thread_target, daemon=True, name="ImageDiff-Processor")
|
||||
thread.start()
|
||||
thread.join(timeout=60)
|
||||
|
||||
|
||||
@@ -361,21 +361,31 @@ class NotificationQueue:
|
||||
Simple wrapper around janus with bulletproof error handling.
|
||||
"""
|
||||
|
||||
def __init__(self, maxsize: int = 0):
|
||||
def __init__(self, maxsize: int = 0, datastore=None):
|
||||
try:
|
||||
self._janus_queue = janus.Queue(maxsize=maxsize)
|
||||
# BOTH interfaces required - see class docstring for why
|
||||
self.sync_q = self._janus_queue.sync_q # Flask routes, threads
|
||||
self.async_q = self._janus_queue.async_q # Async workers
|
||||
self.notification_event_signal = signal('notification_event')
|
||||
self.datastore = datastore # For checking all_muted setting
|
||||
logger.debug("NotificationQueue initialized successfully")
|
||||
except Exception as e:
|
||||
logger.critical(f"CRITICAL: Failed to initialize NotificationQueue: {str(e)}")
|
||||
raise
|
||||
|
||||
def set_datastore(self, datastore):
|
||||
"""Set datastore reference after initialization (for circular dependency handling)"""
|
||||
self.datastore = datastore
|
||||
|
||||
def put(self, item: Dict[str, Any], block: bool = True, timeout: Optional[float] = None):
|
||||
"""Thread-safe sync put with signal emission"""
|
||||
try:
|
||||
# Check if all notifications are muted
|
||||
if self.datastore and self.datastore.data['settings']['application'].get('all_muted', False):
|
||||
logger.debug(f"Notification blocked - all notifications are muted: {item.get('uuid', 'unknown')}")
|
||||
return False
|
||||
|
||||
self.sync_q.put(item, block=block, timeout=timeout)
|
||||
self._emit_notification_signal(item)
|
||||
logger.debug(f"Successfully queued notification: {item.get('uuid', 'unknown')}")
|
||||
@@ -387,6 +397,11 @@ class NotificationQueue:
|
||||
async def async_put(self, item: Dict[str, Any]):
|
||||
"""Pure async put with signal emission"""
|
||||
try:
|
||||
# Check if all notifications are muted
|
||||
if self.datastore and self.datastore.data['settings']['application'].get('all_muted', False):
|
||||
logger.debug(f"Notification blocked - all notifications are muted: {item.get('uuid', 'unknown')}")
|
||||
return False
|
||||
|
||||
await self.async_q.put(item)
|
||||
self._emit_notification_signal(item)
|
||||
logger.debug(f"Successfully async queued notification: {item.get('uuid', 'unknown')}")
|
||||
|
||||
@@ -251,21 +251,32 @@ def init_socketio(app, datastore):
|
||||
from changedetectionio import queuedWatchMetaData
|
||||
from changedetectionio import worker_handler
|
||||
from changedetectionio.flask_app import update_q, watch_check_update
|
||||
import threading
|
||||
|
||||
logger.trace(f"Got checkbox operations event: {data}")
|
||||
|
||||
datastore = socketio.datastore
|
||||
|
||||
_handle_operations(
|
||||
op=data.get('op'),
|
||||
uuids=data.get('uuids'),
|
||||
datastore=datastore,
|
||||
extra_data=data.get('extra_data'),
|
||||
worker_handler=worker_handler,
|
||||
update_q=update_q,
|
||||
queuedWatchMetaData=queuedWatchMetaData,
|
||||
watch_check_update=watch_check_update,
|
||||
emit_flash=False
|
||||
)
|
||||
def run_operation():
|
||||
"""Run the operation in a background thread to avoid blocking the socket.io event loop"""
|
||||
try:
|
||||
_handle_operations(
|
||||
op=data.get('op'),
|
||||
uuids=data.get('uuids'),
|
||||
datastore=datastore,
|
||||
extra_data=data.get('extra_data'),
|
||||
worker_handler=worker_handler,
|
||||
update_q=update_q,
|
||||
queuedWatchMetaData=queuedWatchMetaData,
|
||||
watch_check_update=watch_check_update,
|
||||
emit_flash=False
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in checkbox operation thread: {e}")
|
||||
|
||||
# Start operation in a disposable daemon thread
|
||||
thread = threading.Thread(target=run_operation, daemon=True, name=f"checkbox-op-{data.get('op')}")
|
||||
thread.start()
|
||||
|
||||
@socketio.on('connect')
|
||||
def handle_connect():
|
||||
|
||||
@@ -1 +1 @@
|
||||
.comparison-score{padding:1em;background:var(--color-table-stripe);border-radius:4px;margin:1em 0;border:1px solid var(--color-border-table-cell);color:var(--color-text)}.change-detected{color:#d32f2f;font-weight:bold}.no-change{color:#388e3c;font-weight:bold}.comparison-grid{display:grid;grid-template-columns:1fr 1fr;gap:1em;margin:1em 1em}@media(max-width: 1200px){.comparison-grid{grid-template-columns:1fr}}.image-comparison{position:relative;width:100%;overflow:hidden;border:1px solid var(--color-border-table-cell);box-shadow:0 2px 4px rgba(0, 0, 0, 0.1);user-select:none}.image-comparison img{display:block;width:100%;height:auto;max-width:100%;border:none;box-shadow:none}.comparison-image-wrapper{position:relative;width:100%;display:flex;align-items:flex-start;justify-content:center;background-color:var(--color-background);background-image:linear-gradient(45deg, var(--color-table-stripe) 25%, transparent 25%),linear-gradient(-45deg, var(--color-table-stripe) 25%, transparent 25%),linear-gradient(45deg, transparent 75%, var(--color-table-stripe) 75%),linear-gradient(-45deg, transparent 75%, var(--color-table-stripe) 75%);background-size:20px 20px;background-position:0 0,0 10px,10px -10px,-10px 0px}.comparison-after{position:absolute;top:0;left:0;width:100%;height:100%;clip-path:inset(0 0 0 50%)}.comparison-slider{position:absolute;top:0;left:50%;width:4px;height:100%;background:#0078e7;cursor:ew-resize;transform:translateX(-2px);z-index:10}.comparison-handle{position:absolute;top:50%;left:50%;width:48px;height:48px;background:#0078e7;border:3px solid #fff;border-radius:50%;transform:translate(-50%, -50%);box-shadow:0 2px 8px rgba(0, 0, 0, 0.3);display:flex;align-items:center;justify-content:center;cursor:ew-resize;transition:top .1s ease-out}.comparison-handle::after{content:"⇄";color:#fff;font-size:24px;font-weight:bold;pointer-events:none}.comparison-labels{position:absolute;top:10px;width:100%;display:flex;justify-content:space-between;padding:0 0px;z-index:5;pointer-events:none}.comparison-label{background:rgba(0, 0, 0, 0.7);color:#fff;padding:.5em 1em;border-radius:4px;font-size:.9em;font-weight:bold}.screenshot-panel{text-align:center;background:var(--color-background);border:1px solid var(--color-border-table-cell);border-radius:4px;padding:1em;box-shadow:0 2px 4px rgba(0, 0, 0, 0.05)}.screenshot-panel h3{margin:0 0 1em 0;font-size:1.1em;color:var(--color-text);border-bottom:2px solid var(--color-background-button-primary);padding-bottom:.5em}.screenshot-panel.diff h3{border-bottom-color:#d32f2f}.screenshot-panel img{max-width:100%;height:auto;border:1px solid var(--color-border-table-cell);box-shadow:0 2px 4px rgba(0, 0, 0, 0.1)}.version-selector{display:inline-block;margin:0 .5em}.version-selector label{font-weight:bold;margin-right:.5em;color:var(--color-text)}#settings{background:var(--color-background);padding:1.5em;border-radius:4px;box-shadow:0 2px 4px rgba(0, 0, 0, 0.05);margin-bottom:2em;border:1px solid var(--color-border-table-cell)}#settings h2{margin-top:0;color:var(--color-text)}.diff-fieldset{border:none;padding:0;margin:0}.edit-link{float:right;margin-top:-0.5em}.comparison-description{color:var(--color-text-input-description);font-size:.9em;margin-bottom:1em}.download-link{color:var(--color-link);text-decoration:none;display:inline-flex;align-items:center;gap:.3em;font-size:.85em}.download-link:hover{text-decoration:underline}.diff-section-header{color:#d32f2f;font-size:.9em;margin-bottom:1em;font-weight:bold;display:flex;align-items:center;justify-content:center;gap:1em}.comparison-history-section{margin-top:3em;padding:1em;background:var(--color-background);border:1px solid var(--color-border-table-cell);border-radius:4px;box-shadow:0 2px 4px rgba(0, 0, 0, 0.05)}.comparison-history-section h3{color:var(--color-text)}.comparison-history-section p{color:var(--color-text-input-description);font-size:.9em}.history-changed-yes{color:#d32f2f;font-weight:bold}.history-changed-no{color:#388e3c}
|
||||
.comparison-score{padding:1em;background:var(--color-table-stripe);border-radius:4px;margin:1em 0;border:1px solid var(--color-border-table-cell);color:var(--color-text)}.change-detected{color:#d32f2f;font-weight:bold}.no-change{color:#388e3c;font-weight:bold}.comparison-grid{display:grid;grid-template-columns:1fr 1fr;gap:1em;margin:1em 1em}@media(max-width: 1200px){.comparison-grid{grid-template-columns:1fr}}.image-comparison{position:relative;width:100%;overflow:hidden;border:1px solid var(--color-border-table-cell);box-shadow:0 2px 4px rgba(0,0,0,.1);user-select:none}.image-comparison img{display:block;width:100%;height:auto;max-width:100%;border:none;box-shadow:none}.comparison-image-wrapper{position:relative;width:100%;display:flex;align-items:flex-start;justify-content:center;background-color:var(--color-background);background-image:linear-gradient(45deg, var(--color-table-stripe) 25%, transparent 25%),linear-gradient(-45deg, var(--color-table-stripe) 25%, transparent 25%),linear-gradient(45deg, transparent 75%, var(--color-table-stripe) 75%),linear-gradient(-45deg, transparent 75%, var(--color-table-stripe) 75%);background-size:20px 20px;background-position:0 0,0 10px,10px -10px,-10px 0px}.comparison-after{position:absolute;top:0;left:0;width:100%;height:100%;clip-path:inset(0 0 0 50%)}.comparison-slider{position:absolute;top:0;left:50%;width:4px;height:100%;background:#0078e7;cursor:ew-resize;transform:translateX(-2px);z-index:10}.comparison-handle{position:absolute;top:50%;left:50%;width:48px;height:48px;background:#0078e7;border:3px solid #fff;border-radius:50%;transform:translate(-50%, -50%);box-shadow:0 2px 8px rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center;cursor:ew-resize;transition:top .1s ease-out}.comparison-handle::after{content:"⇄";color:#fff;font-size:24px;font-weight:bold;pointer-events:none}.comparison-labels{position:absolute;top:10px;width:100%;display:flex;justify-content:space-between;padding:0 0px;z-index:5;pointer-events:none}.comparison-label{background:rgba(0,0,0,.7);color:#fff;padding:.5em 1em;border-radius:4px;font-size:.9em;font-weight:bold}.screenshot-panel{text-align:center;background:var(--color-background);border:1px solid var(--color-border-table-cell);border-radius:4px;padding:1em;box-shadow:0 2px 4px rgba(0,0,0,.05)}.screenshot-panel h3{margin:0 0 1em 0;font-size:1.1em;color:var(--color-text);border-bottom:2px solid var(--color-background-button-primary);padding-bottom:.5em}.screenshot-panel.diff h3{border-bottom-color:#d32f2f}.screenshot-panel img{max-width:100%;height:auto;border:1px solid var(--color-border-table-cell);box-shadow:0 2px 4px rgba(0,0,0,.1)}.version-selector{display:inline-block;margin:0 .5em}.version-selector label{font-weight:bold;margin-right:.5em;color:var(--color-text)}#settings{background:var(--color-background);padding:1.5em;border-radius:4px;box-shadow:0 2px 4px rgba(0,0,0,.05);margin-bottom:2em;border:1px solid var(--color-border-table-cell)}#settings h2{margin-top:0;color:var(--color-text)}.diff-fieldset{border:none;padding:0;margin:0}.edit-link{float:right;margin-top:-0.5em}.comparison-description{color:var(--color-text-input-description);font-size:.9em;margin-bottom:1em}.download-link{color:var(--color-link);text-decoration:none;display:inline-flex;align-items:center;gap:.3em;font-size:.85em}.download-link:hover{text-decoration:underline}.diff-section-header{color:#d32f2f;font-size:.9em;margin-bottom:1em;font-weight:bold;display:flex;align-items:center;justify-content:center;gap:1em}.comparison-history-section{margin-top:3em;padding:1em;background:var(--color-background);border:1px solid var(--color-border-table-cell);border-radius:4px;box-shadow:0 2px 4px rgba(0,0,0,.05)}.comparison-history-section h3{color:var(--color-text)}.comparison-history-section p{color:var(--color-text-input-description);font-size:.9em}.history-changed-yes{color:#d32f2f;font-weight:bold}.history-changed-no{color:#388e3c}
|
||||
|
||||
@@ -1 +1 @@
|
||||
#diff-form{background:rgba(0, 0, 0, 0.05);padding:1em;border-radius:10px;margin-bottom:1em;color:#fff;font-size:.9rem;text-align:center}#diff-form label.from-to-label{width:4rem;text-decoration:none;padding:.5rem}#diff-form label.from-to-label#change-from{color:#b30000;background:#fadad7}#diff-form label.from-to-label#change-to{background:#eaf2c2;color:#406619}#diff-form #diff-style>span{display:inline-block;padding:.3em}#diff-form #diff-style>span label{font-weight:normal}#diff-form *{vertical-align:middle}body.difference-page section.content{padding-top:40px}#diff-ui{background:var(--color-background);padding:1rem;border-radius:5px}@media(min-width: 767px){#diff-ui{min-width:50%}}#diff-ui #text{font-size:11px}#diff-ui pre{white-space:break-spaces}#diff-ui h1{display:inline;font-size:100%}#diff-ui #result{white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word}#diff-ui .source{position:absolute;right:1%;top:.2em}@-moz-document url-prefix(){#diff-ui body{height:99%}}#diff-ui td#diff-col div{text-align:justify;white-space:pre-wrap}#diff-ui .ignored{background-color:#ccc;opacity:.7}#diff-ui .triggered{background-color:#1b98f8}#diff-ui .ignored.triggered{background-color:red}#diff-ui .tab-pane-inner#screenshot{text-align:center}#diff-ui .tab-pane-inner#screenshot img{max-width:99%}#diff-ui .pure-form button.reset-margin{margin:0px}#diff-ui .diff-fieldset{display:flex;align-items:center;gap:4px;flex-wrap:wrap}#diff-ui ul#highlightSnippetActions{list-style-type:none;display:flex;align-items:center;justify-content:center;gap:1.5rem;flex-wrap:wrap;padding:0;margin:0}#diff-ui ul#highlightSnippetActions li{display:flex;flex-direction:column;align-items:center;text-align:center;padding:.5rem;gap:.3rem}#diff-ui ul#highlightSnippetActions li button,#diff-ui ul#highlightSnippetActions li a{white-space:nowrap}#diff-ui ul#highlightSnippetActions span{font-size:.8rem;color:var(--color-text-input-description)}#diff-ui #cell-diff-jump-visualiser{display:flex;flex-direction:row;gap:1px;background:var(--color-background);border-radius:3px;overflow-x:hidden;position:sticky;top:0;z-index:10;padding-top:1rem;padding-bottom:1rem;justify-content:center}#diff-ui #cell-diff-jump-visualiser>div{flex:1;min-width:1px;max-width:10px;height:10px;background:var(--color-background-button-cancel);opacity:.3;border-radius:1px;transition:opacity .2s;position:relative}#diff-ui #cell-diff-jump-visualiser>div.deletion{background:#b30000;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.insertion{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.note{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.mixed{background:linear-gradient(to right, #b30000 50%, #406619 50%);opacity:1}#diff-ui #cell-diff-jump-visualiser>div.current-position::after{content:"";position:absolute;bottom:-6px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:4px solid rgba(0, 0, 0, 0);border-right:4px solid rgba(0, 0, 0, 0);border-bottom:4px solid var(--color-text)}#diff-ui #cell-diff-jump-visualiser>div:hover{opacity:.8;cursor:pointer}#text-diff-heading-area .snapshot-age{padding:4px;margin:.5rem 0;background-color:var(--color-background-snapshot-age);border-radius:3px;font-weight:bold;margin-bottom:4px}#text-diff-heading-area .snapshot-age.error{background-color:var(--color-error-background-snapshot-age);color:var(--color-error-text-snapshot-age)}#text-diff-heading-area .snapshot-age>*{padding-right:1rem}
|
||||
#diff-form{background:rgba(0,0,0,.05);padding:1em;border-radius:10px;margin-bottom:1em;color:#fff;font-size:.9rem;text-align:center}#diff-form label.from-to-label{width:4rem;text-decoration:none;padding:.5rem}#diff-form label.from-to-label#change-from{color:#b30000;background:#fadad7}#diff-form label.from-to-label#change-to{background:#eaf2c2;color:#406619}#diff-form #diff-style>span{display:inline-block;padding:.3em}#diff-form #diff-style>span label{font-weight:normal}#diff-form *{vertical-align:middle}body.difference-page section.content{padding-top:40px}#diff-ui{background:var(--color-background);padding:1rem;border-radius:5px}@media(min-width: 767px){#diff-ui{min-width:50%}}#diff-ui #text{font-size:11px}#diff-ui pre{white-space:break-spaces}#diff-ui h1{display:inline;font-size:100%}#diff-ui #result{white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word}#diff-ui .source{position:absolute;right:1%;top:.2em}@-moz-document url-prefix(){#diff-ui body{height:99%}}#diff-ui td#diff-col div{text-align:justify;white-space:pre-wrap}#diff-ui .ignored{background-color:#ccc;opacity:.7}#diff-ui .triggered{background-color:#1b98f8}#diff-ui .ignored.triggered{background-color:red}#diff-ui .tab-pane-inner#screenshot{text-align:center}#diff-ui .tab-pane-inner#screenshot img{max-width:99%}#diff-ui .pure-form button.reset-margin{margin:0px}#diff-ui .diff-fieldset{display:flex;align-items:center;gap:4px;flex-wrap:wrap}#diff-ui ul#highlightSnippetActions{list-style-type:none;display:flex;align-items:center;justify-content:center;gap:1.5rem;flex-wrap:wrap;padding:0;margin:0}#diff-ui ul#highlightSnippetActions li{display:flex;flex-direction:column;align-items:center;text-align:center;padding:.5rem;gap:.3rem}#diff-ui ul#highlightSnippetActions li button,#diff-ui ul#highlightSnippetActions li a{white-space:nowrap}#diff-ui ul#highlightSnippetActions span{font-size:.8rem;color:var(--color-text-input-description)}#diff-ui #cell-diff-jump-visualiser{display:flex;flex-direction:row;gap:1px;background:var(--color-background);border-radius:3px;overflow-x:hidden;position:sticky;top:0;z-index:10;padding-top:1rem;padding-bottom:1rem;justify-content:center}#diff-ui #cell-diff-jump-visualiser>div{flex:1;min-width:1px;max-width:10px;height:10px;background:var(--color-background-button-cancel);opacity:.3;border-radius:1px;transition:opacity .2s;position:relative}#diff-ui #cell-diff-jump-visualiser>div.deletion{background:#b30000;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.insertion{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.note{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.mixed{background:linear-gradient(to right, #b30000 50%, #406619 50%);opacity:1}#diff-ui #cell-diff-jump-visualiser>div.current-position::after{content:"";position:absolute;bottom:-6px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:4px solid rgba(0,0,0,0);border-right:4px solid rgba(0,0,0,0);border-bottom:4px solid var(--color-text)}#diff-ui #cell-diff-jump-visualiser>div:hover{opacity:.8;cursor:pointer}#text-diff-heading-area .snapshot-age{padding:4px;margin:.5rem 0;background-color:var(--color-background-snapshot-age);border-radius:3px;font-weight:bold;margin-bottom:4px}#text-diff-heading-area .snapshot-age.error{background-color:var(--color-error-background-snapshot-age);color:var(--color-error-text-snapshot-age)}#text-diff-heading-area .snapshot-age>*{padding-right:1rem}
|
||||
|
||||
@@ -110,6 +110,9 @@
|
||||
background: var(--color-background-menu-link-hover);
|
||||
}
|
||||
}
|
||||
&#menu-pause, &#menu-mute {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
padding: 0.5rem 1em;
|
||||
line-height: 1.2rem;
|
||||
}
|
||||
#menu-mute, #menu-pause {
|
||||
padding-left: 0.3rem;
|
||||
padding-right: 0.3rem;
|
||||
img {
|
||||
height: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.pure-menu-item {
|
||||
svg {
|
||||
|
||||
@@ -184,24 +184,24 @@ html[data-darkmode=true] {
|
||||
// Mobile adjustments
|
||||
@media only screen and (max-width: 768px) {
|
||||
.toast-container {
|
||||
left: 10px !important;
|
||||
right: 10px !important;
|
||||
top: 10px !important;
|
||||
transform: none !important;
|
||||
align-items: stretch;
|
||||
left: 50% !important;
|
||||
right: auto !important;
|
||||
top: 80px !important;
|
||||
transform: translateX(-50%) !important;
|
||||
align-items: center;
|
||||
|
||||
&.toast-bottom-right,
|
||||
&.toast-bottom-center,
|
||||
&.toast-bottom-left {
|
||||
top: auto !important;
|
||||
bottom: 10px !important;
|
||||
bottom: 80px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: auto;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
width: 80vw;
|
||||
transform: translateY(-100px);
|
||||
|
||||
&.toast-show {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -13,8 +13,11 @@
|
||||
<li class="pure-menu-item menu-collapsible {% if request.endpoint.startswith('imports.') %}active{% endif %}">
|
||||
<a href="{{ url_for('imports.import_page') }}" class="pure-menu-link">{{ _('IMPORT') }}</a>
|
||||
</li>
|
||||
<li class="pure-menu-item menu-collapsible {% if request.endpoint.startswith('backups.') %}active{% endif %}">
|
||||
<a href="{{ url_for('backups.index') }}" class="pure-menu-link">{{ _('BACKUPS') }}</a>
|
||||
<li class="pure-menu-item" id="menu-pause">
|
||||
<a href="{{ url_for('settings.toggle_all_paused') }}" ><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="{% if all_paused %}Resume automatic scheduling{% else %}Pause auto-queue scheduling of watches{% endif %}" title="{% if all_paused %}Scheduling is paused - click to resume{% else %}Pause auto-queue scheduling of watches{% endif %}" class="icon icon-pause"{% if not all_paused %} style="opacity: 0.3"{% endif %}></a>
|
||||
</li>
|
||||
<li class="pure-menu-item " id="menu-mute">
|
||||
<a href="{{ url_for('settings.toggle_all_muted') }}" ><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="{% if all_muted %}Unmute notifications{% else %}Mute notifications{% endif %}" title="{% if all_muted %}Notifications are muted - click to unmute{% else %}Mute notifications{% endif %}" class="icon icon-mute"{% if not all_muted %} style="opacity: 0.3"{% endif %}></a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="pure-menu-item menu-collapsible">
|
||||
|
||||
@@ -159,6 +159,14 @@ def prepare_test_function(live_server, datastore_path):
|
||||
# CRITICAL: Get datastore and stop it from writing stale data
|
||||
datastore = live_server.app.config.get('DATASTORE')
|
||||
|
||||
# Clear the queue before starting the test to prevent state leakage
|
||||
from changedetectionio.flask_app import update_q
|
||||
while not update_q.empty():
|
||||
try:
|
||||
update_q.get_nowait()
|
||||
except:
|
||||
break
|
||||
|
||||
# Prevent background thread from writing during cleanup/reload
|
||||
datastore.needs_write = False
|
||||
datastore.needs_write_urgent = False
|
||||
@@ -183,8 +191,17 @@ def prepare_test_function(live_server, datastore_path):
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup: Clear watches again after test
|
||||
# Cleanup: Clear watches and queue after test
|
||||
try:
|
||||
from changedetectionio.flask_app import update_q
|
||||
|
||||
# Clear the queue to prevent leakage to next test
|
||||
while not update_q.empty():
|
||||
try:
|
||||
update_q.get_nowait()
|
||||
except:
|
||||
break
|
||||
|
||||
datastore.data['watching'] = {}
|
||||
datastore.needs_write = True
|
||||
except Exception as e:
|
||||
@@ -238,17 +255,20 @@ def app(request, datastore_path):
|
||||
app.config['TEST_DATASTORE_PATH'] = datastore_path
|
||||
|
||||
def teardown():
|
||||
import threading
|
||||
import time
|
||||
|
||||
# Stop all threads and services
|
||||
datastore.stop_thread = True
|
||||
app.config.exit.set()
|
||||
|
||||
|
||||
# Shutdown workers gracefully before loguru cleanup
|
||||
try:
|
||||
from changedetectionio import worker_handler
|
||||
worker_handler.shutdown_workers()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Stop socket server threads
|
||||
try:
|
||||
from changedetectionio.flask_app import socketio_server
|
||||
@@ -256,14 +276,35 @@ def app(request, datastore_path):
|
||||
socketio_server.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Give threads a moment to finish their shutdown
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
# Get all active threads before cleanup
|
||||
main_thread = threading.main_thread()
|
||||
active_threads = [t for t in threading.enumerate() if t != main_thread and t.is_alive()]
|
||||
|
||||
# Wait for non-daemon threads to finish (with timeout)
|
||||
timeout = 2.0 # 2 seconds max wait
|
||||
start_time = time.time()
|
||||
|
||||
for thread in active_threads:
|
||||
if not thread.daemon:
|
||||
remaining_time = timeout - (time.time() - start_time)
|
||||
if remaining_time > 0:
|
||||
logger.debug(f"Waiting for non-daemon thread to finish: {thread.name}")
|
||||
thread.join(timeout=remaining_time)
|
||||
if thread.is_alive():
|
||||
logger.warning(f"Thread {thread.name} did not finish in time")
|
||||
|
||||
# Give daemon threads a moment to finish their current work
|
||||
time.sleep(0.2)
|
||||
|
||||
# Log any threads still running
|
||||
remaining_threads = [t for t in threading.enumerate() if t != main_thread and t.is_alive()]
|
||||
if remaining_threads:
|
||||
logger.debug(f"Threads still running after teardown: {[t.name for t in remaining_threads]}")
|
||||
|
||||
# Remove all loguru handlers to prevent "closed file" errors
|
||||
logger.remove()
|
||||
|
||||
|
||||
# Cleanup files
|
||||
cleanup(app_config['datastore_path'])
|
||||
|
||||
|
||||
@@ -124,7 +124,6 @@ def test_check_access_control(app, client, live_server, measure_memory_usage, da
|
||||
|
||||
# Menu should be available now
|
||||
assert b"SETTINGS" in res.data
|
||||
assert b"BACKUP" in res.data
|
||||
assert b"IMPORT" in res.data
|
||||
assert b"LOG OUT" in res.data
|
||||
assert b"time_between_check-minutes" in res.data
|
||||
|
||||
@@ -109,18 +109,17 @@ def test_api_simple(client, live_server, measure_memory_usage, datastore_path):
|
||||
headers={'x-api-key': api_key}
|
||||
)
|
||||
assert len(res.json) == 0
|
||||
time.sleep(1)
|
||||
time.sleep(2)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
set_modified_response(datastore_path=datastore_path)
|
||||
# Trigger recheck of all ?recheck_all=1
|
||||
client.get(
|
||||
res = client.get(
|
||||
url_for("createwatch", recheck_all='1'),
|
||||
headers={'x-api-key': api_key},
|
||||
)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
time.sleep(1)
|
||||
time.sleep(2)
|
||||
# Did the recheck fire?
|
||||
res = client.get(
|
||||
url_for("createwatch"),
|
||||
|
||||
@@ -125,10 +125,12 @@ def test_api_tags_listing(client, live_server, measure_memory_usage, datastore_p
|
||||
url_for("tag", uuid=new_tag_uuid) + "?recheck=true",
|
||||
headers={'x-api-key': api_key}
|
||||
)
|
||||
wait_for_all_checks()
|
||||
assert res.status_code == 200
|
||||
assert b'OK, 1 watches' in res.data
|
||||
|
||||
assert res.status_code == 200
|
||||
assert b'OK, queued 1 watches for rechecking' in res.data
|
||||
|
||||
|
||||
wait_for_all_checks()
|
||||
after_check_time = live_server.app.config['DATASTORE'].data['watching'][watch_uuid].get('last_checked')
|
||||
|
||||
assert before_check_time != after_check_time
|
||||
|
||||
@@ -38,7 +38,6 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
|
||||
# Default no password set, this stuff should be always available.
|
||||
|
||||
assert b"SETTINGS" in res.data
|
||||
assert b"BACKUP" in res.data
|
||||
assert b"IMPORT" in res.data
|
||||
|
||||
#####################
|
||||
|
||||
205
changedetectionio/tests/unit/test_html_to_text.py
Normal file
205
changedetectionio/tests/unit/test_html_to_text.py
Normal file
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
# coding=utf-8
|
||||
|
||||
"""Unit tests for html_tools.html_to_text function."""
|
||||
|
||||
import hashlib
|
||||
import threading
|
||||
import unittest
|
||||
from queue import Queue
|
||||
|
||||
from changedetectionio.html_tools import html_to_text
|
||||
|
||||
|
||||
class TestHtmlToText(unittest.TestCase):
|
||||
"""Test html_to_text function for correctness and thread-safety."""
|
||||
|
||||
def test_basic_text_extraction(self):
|
||||
"""Test basic HTML to text conversion."""
|
||||
html = '<html><body><h1>Title</h1><p>Paragraph text.</p></body></html>'
|
||||
text = html_to_text(html)
|
||||
|
||||
assert 'Title' in text
|
||||
assert 'Paragraph text.' in text
|
||||
assert '<' not in text # HTML tags should be stripped
|
||||
assert '>' not in text
|
||||
|
||||
def test_empty_html(self):
|
||||
"""Test handling of empty HTML."""
|
||||
html = '<html><body></body></html>'
|
||||
text = html_to_text(html)
|
||||
|
||||
# Should return empty or whitespace only
|
||||
assert text.strip() == ''
|
||||
|
||||
def test_nested_elements(self):
|
||||
"""Test extraction from nested HTML elements."""
|
||||
html = '''
|
||||
<html>
|
||||
<body>
|
||||
<div>
|
||||
<h1>Header</h1>
|
||||
<div>
|
||||
<p>First paragraph</p>
|
||||
<p>Second paragraph</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
text = html_to_text(html)
|
||||
|
||||
assert 'Header' in text
|
||||
assert 'First paragraph' in text
|
||||
assert 'Second paragraph' in text
|
||||
|
||||
def test_anchor_tag_rendering(self):
|
||||
"""Test anchor tag rendering option."""
|
||||
html = '<html><body><a href="https://example.com">Link text</a></body></html>'
|
||||
|
||||
# Without rendering anchors
|
||||
text_without = html_to_text(html, render_anchor_tag_content=False)
|
||||
assert 'Link text' in text_without
|
||||
assert 'https://example.com' not in text_without
|
||||
|
||||
# With rendering anchors
|
||||
text_with = html_to_text(html, render_anchor_tag_content=True)
|
||||
assert 'Link text' in text_with
|
||||
assert 'https://example.com' in text_with or '[Link text]' in text_with
|
||||
|
||||
def test_rss_mode(self):
|
||||
"""Test RSS mode converts title tags to h1."""
|
||||
html = '<item><title>RSS Title</title><description>Content</description></item>'
|
||||
|
||||
# is_rss=True should convert <title> to <h1>
|
||||
text = html_to_text(html, is_rss=True)
|
||||
|
||||
assert 'RSS Title' in text
|
||||
assert 'Content' in text
|
||||
|
||||
def test_special_characters(self):
|
||||
"""Test handling of special characters and entities."""
|
||||
html = '<html><body><p>Test & <special> characters</p></body></html>'
|
||||
text = html_to_text(html)
|
||||
|
||||
# Entities should be decoded
|
||||
assert 'Test &' in text or 'Test &' in text
|
||||
assert 'special' in text
|
||||
|
||||
def test_whitespace_handling(self):
|
||||
"""Test that whitespace is properly handled."""
|
||||
html = '<html><body><p>Line 1</p><p>Line 2</p></body></html>'
|
||||
text = html_to_text(html)
|
||||
|
||||
# Should have some separation between lines
|
||||
assert 'Line 1' in text
|
||||
assert 'Line 2' in text
|
||||
assert text.count('\n') >= 1 # At least one newline
|
||||
|
||||
def test_deterministic_output(self):
|
||||
"""Test that the same HTML always produces the same text."""
|
||||
html = '<html><body><h1>Test</h1><p>Content here</p></body></html>'
|
||||
|
||||
# Extract text multiple times
|
||||
results = [html_to_text(html) for _ in range(10)]
|
||||
|
||||
# All results should be identical
|
||||
assert len(set(results)) == 1, "html_to_text should be deterministic"
|
||||
|
||||
def test_thread_safety_determinism(self):
|
||||
"""
|
||||
Test that html_to_text produces deterministic output under high concurrency.
|
||||
|
||||
This verifies that lxml's default parser (used by inscriptis.get_text)
|
||||
is thread-safe and produces consistent results when called from multiple
|
||||
threads simultaneously.
|
||||
"""
|
||||
html = '''
|
||||
<html>
|
||||
<head><title>Test Page</title></head>
|
||||
<body>
|
||||
<h1>Main Heading</h1>
|
||||
<div class="content">
|
||||
<p>First paragraph with <b>bold text</b>.</p>
|
||||
<p>Second paragraph with <i>italic text</i>.</p>
|
||||
<ul>
|
||||
<li>Item 1</li>
|
||||
<li>Item 2</li>
|
||||
<li>Item 3</li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
results_queue = Queue()
|
||||
|
||||
def worker(worker_id, iterations=10):
|
||||
"""Worker that converts HTML to text multiple times."""
|
||||
for i in range(iterations):
|
||||
text = html_to_text(html)
|
||||
md5 = hashlib.md5(text.encode('utf-8')).hexdigest()
|
||||
results_queue.put((worker_id, i, md5))
|
||||
|
||||
# Launch many threads simultaneously
|
||||
num_threads = 50
|
||||
threads = []
|
||||
|
||||
for i in range(num_threads):
|
||||
t = threading.Thread(target=worker, args=(i,))
|
||||
threads.append(t)
|
||||
t.start()
|
||||
|
||||
# Wait for all threads to complete
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Collect all MD5 results
|
||||
md5_values = []
|
||||
while not results_queue.empty():
|
||||
_, _, md5 = results_queue.get()
|
||||
md5_values.append(md5)
|
||||
|
||||
# All MD5s should be identical
|
||||
unique_md5s = set(md5_values)
|
||||
|
||||
assert len(unique_md5s) == 1, (
|
||||
f"Thread-safety issue detected! Found {len(unique_md5s)} different MD5 values: {unique_md5s}. "
|
||||
"The thread-local parser fix may not be working correctly."
|
||||
)
|
||||
|
||||
print(f"✓ Thread-safety test passed: {len(md5_values)} conversions, all identical")
|
||||
|
||||
def test_thread_safety_basic(self):
|
||||
"""Verify basic thread safety - multiple threads can call html_to_text simultaneously."""
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
def worker():
|
||||
"""Worker that converts HTML to text."""
|
||||
try:
|
||||
html = '<html><body><h1>Test</h1><p>Content</p></body></html>'
|
||||
text = html_to_text(html)
|
||||
results.append(text)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
# Launch 10 threads simultaneously
|
||||
threads = [threading.Thread(target=worker) for _ in range(10)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Should have no errors
|
||||
assert len(errors) == 0, f"Thread-safety errors occurred: {errors}"
|
||||
|
||||
# All results should be identical
|
||||
assert len(set(results)) == 1, "All threads should produce identical output"
|
||||
|
||||
print(f"✓ Basic thread-safety test passed: {len(results)} threads, no errors")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Can run this file directly for quick testing
|
||||
unittest.main()
|
||||
Binary file not shown.
@@ -757,12 +757,17 @@ msgstr "IMPORTOVAT"
|
||||
msgid "Password protection removed."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:98
|
||||
#: changedetectionio/blueprint/settings/__init__.py:92
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr "Upozornění: Počet workerů ({}) se blíží nebo překračuje dostupné CPU jádra ({})"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:104
|
||||
#, python-brace-format
|
||||
msgid "Worker count adjusted: {}"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:100
|
||||
#: changedetectionio/blueprint/settings/__init__.py:106
|
||||
msgid "Dynamic worker adjustment not supported for sync workers"
|
||||
msgstr ""
|
||||
|
||||
@@ -1123,10 +1128,10 @@ msgstr ""
|
||||
msgid "Queued 1 watch for rechecking."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py:257
|
||||
#: changedetectionio/blueprint/ui/__init__.py:283
|
||||
#, python-brace-format
|
||||
msgid "Queued {} watches for rechecking."
|
||||
msgstr ""
|
||||
msgstr "Do fronty přidáno {} sledování k opětovné kontrole."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py:259
|
||||
msgid "No watches available to recheck."
|
||||
@@ -2148,8 +2153,8 @@ msgid "IMPORT"
|
||||
msgstr "IMPORTOVAT"
|
||||
|
||||
#: changedetectionio/templates/base.html:86
|
||||
msgid "BACKUPS"
|
||||
msgstr "BACKUPS"
|
||||
msgid "Backups"
|
||||
msgstr "Backups"
|
||||
|
||||
#: changedetectionio/templates/base.html:90
|
||||
msgid "EDIT"
|
||||
|
||||
Binary file not shown.
@@ -579,7 +579,7 @@ msgstr "Backups wurden gelöscht."
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html:6
|
||||
msgid "Backups"
|
||||
msgstr "BACKUPS"
|
||||
msgstr "Backups"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/overview.html:9
|
||||
msgid "A backup is running!"
|
||||
@@ -758,14 +758,19 @@ msgstr "IMPORT"
|
||||
msgid "Password protection removed."
|
||||
msgstr "Passwortschutz entfernt."
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:98
|
||||
#: changedetectionio/blueprint/settings/__init__.py:92
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr "Warnung: Anzahl der Worker ({}) nähert sich oder überschreitet die verfügbaren CPU-Kerne ({})"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:104
|
||||
#, python-brace-format
|
||||
msgid "Worker count adjusted: {}"
|
||||
msgstr "Anzahl der \"Worker\" geändert auf: {}"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:100
|
||||
#: changedetectionio/blueprint/settings/__init__.py:106
|
||||
msgid "Dynamic worker adjustment not supported for sync workers"
|
||||
msgstr "Die dynamische Anpassung von „Worker“ wird für Synchronisierungs-„Worker“ nicht unterstützt."
|
||||
msgstr "Die dynamische Anpassung von „Worker" wird für Synchronisierungs-„Worker" nicht unterstützt."
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:102
|
||||
#, python-brace-format
|
||||
@@ -1124,7 +1129,7 @@ msgstr "Geklont, Sie bearbeiten die neue Beobachtung."
|
||||
msgid "Queued 1 watch for rechecking."
|
||||
msgstr "1 Überwachung zur erneuten Überprüfung in Warteschlange gestellt."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py:257
|
||||
#: changedetectionio/blueprint/ui/__init__.py:283
|
||||
#, python-brace-format
|
||||
msgid "Queued {} watches for rechecking."
|
||||
msgstr "In der Warteschlange {} zur erneuten Überprüfung."
|
||||
@@ -1205,7 +1210,7 @@ msgstr "Möglicherweise möchten Sie die verwenden"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:13
|
||||
msgid "BACKUP"
|
||||
msgstr "BACKUPS"
|
||||
msgstr "Backups"
|
||||
|
||||
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:13
|
||||
msgid "link first."
|
||||
@@ -2149,8 +2154,8 @@ msgid "IMPORT"
|
||||
msgstr "IMPORTIEREN"
|
||||
|
||||
#: changedetectionio/templates/base.html:86
|
||||
msgid "BACKUPS"
|
||||
msgstr "BACKUPS"
|
||||
msgid "Backups"
|
||||
msgstr "Backups"
|
||||
|
||||
#: changedetectionio/templates/base.html:90
|
||||
msgid "EDIT"
|
||||
|
||||
@@ -757,12 +757,17 @@ msgstr ""
|
||||
msgid "Password protection removed."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:98
|
||||
#: changedetectionio/blueprint/settings/__init__.py:92
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:104
|
||||
#, python-brace-format
|
||||
msgid "Worker count adjusted: {}"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:100
|
||||
#: changedetectionio/blueprint/settings/__init__.py:106
|
||||
msgid "Dynamic worker adjustment not supported for sync workers"
|
||||
msgstr ""
|
||||
|
||||
@@ -1123,7 +1128,7 @@ msgstr ""
|
||||
msgid "Queued 1 watch for rechecking."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py:257
|
||||
#: changedetectionio/blueprint/ui/__init__.py:283
|
||||
#, python-brace-format
|
||||
msgid "Queued {} watches for rechecking."
|
||||
msgstr ""
|
||||
@@ -2148,7 +2153,7 @@ msgid "IMPORT"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/base.html:86
|
||||
msgid "BACKUPS"
|
||||
msgid "Backups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/base.html:90
|
||||
|
||||
@@ -757,12 +757,17 @@ msgstr ""
|
||||
msgid "Password protection removed."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:98
|
||||
#: changedetectionio/blueprint/settings/__init__.py:92
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:104
|
||||
#, python-brace-format
|
||||
msgid "Worker count adjusted: {}"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:100
|
||||
#: changedetectionio/blueprint/settings/__init__.py:106
|
||||
msgid "Dynamic worker adjustment not supported for sync workers"
|
||||
msgstr ""
|
||||
|
||||
@@ -1123,7 +1128,7 @@ msgstr ""
|
||||
msgid "Queued 1 watch for rechecking."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py:257
|
||||
#: changedetectionio/blueprint/ui/__init__.py:283
|
||||
#, python-brace-format
|
||||
msgid "Queued {} watches for rechecking."
|
||||
msgstr ""
|
||||
@@ -2148,7 +2153,7 @@ msgid "IMPORT"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/base.html:86
|
||||
msgid "BACKUPS"
|
||||
msgid "Backups"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/base.html:90
|
||||
|
||||
Binary file not shown.
@@ -757,12 +757,17 @@ msgstr "IMPORTER"
|
||||
msgid "Password protection removed."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:98
|
||||
#: changedetectionio/blueprint/settings/__init__.py:92
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr "Avertissement: Le nombre de workers ({}) approche ou dépasse les cœurs CPU disponibles ({})"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:104
|
||||
#, python-brace-format
|
||||
msgid "Worker count adjusted: {}"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:100
|
||||
#: changedetectionio/blueprint/settings/__init__.py:106
|
||||
msgid "Dynamic worker adjustment not supported for sync workers"
|
||||
msgstr ""
|
||||
|
||||
@@ -1123,10 +1128,10 @@ msgstr ""
|
||||
msgid "Queued 1 watch for rechecking."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py:257
|
||||
#: changedetectionio/blueprint/ui/__init__.py:283
|
||||
#, python-brace-format
|
||||
msgid "Queued {} watches for rechecking."
|
||||
msgstr ""
|
||||
msgstr "{} surveillances mises en file d'attente pour revérification."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py:259
|
||||
msgid "No watches available to recheck."
|
||||
@@ -2148,7 +2153,7 @@ msgid "IMPORT"
|
||||
msgstr "IMPORTER"
|
||||
|
||||
#: changedetectionio/templates/base.html:86
|
||||
msgid "BACKUPS"
|
||||
msgid "Backups"
|
||||
msgstr "SAUVEGARDES"
|
||||
|
||||
#: changedetectionio/templates/base.html:90
|
||||
|
||||
Binary file not shown.
@@ -776,7 +776,12 @@ msgstr "Importa"
|
||||
msgid "Password protection removed."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:98
|
||||
#: changedetectionio/blueprint/settings/__init__.py:92
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr "Avviso: Il numero di worker ({}) si avvicina o supera i core CPU disponibili ({})"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:104
|
||||
#, python-brace-format
|
||||
msgid "Worker count adjusted: {}"
|
||||
msgstr ""
|
||||
@@ -1142,10 +1147,10 @@ msgstr ""
|
||||
msgid "Queued 1 watch for rechecking."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py:257
|
||||
#: changedetectionio/blueprint/ui/__init__.py:283
|
||||
#, python-brace-format
|
||||
msgid "Queued {} watches for rechecking."
|
||||
msgstr ""
|
||||
msgstr "{} monitoraggi accodati per la riverifica."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py:259
|
||||
msgid "No watches available to recheck."
|
||||
@@ -2168,7 +2173,7 @@ msgid "IMPORT"
|
||||
msgstr "IMPORTA"
|
||||
|
||||
#: changedetectionio/templates/base.html:86
|
||||
msgid "BACKUPS"
|
||||
msgid "Backups"
|
||||
msgstr "BACKUP"
|
||||
|
||||
#: changedetectionio/templates/base.html:90
|
||||
|
||||
Binary file not shown.
@@ -792,12 +792,17 @@ msgstr "수입"
|
||||
msgid "Password protection removed."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:98
|
||||
#: changedetectionio/blueprint/settings/__init__.py:92
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr "경고: 워커 수({})가 사용 가능한 CPU 코어 수({})에 근접하거나 초과합니다"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:104
|
||||
#, python-brace-format
|
||||
msgid "Worker count adjusted: {}"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:100
|
||||
#: changedetectionio/blueprint/settings/__init__.py:106
|
||||
msgid "Dynamic worker adjustment not supported for sync workers"
|
||||
msgstr ""
|
||||
|
||||
@@ -1165,10 +1170,10 @@ msgstr ""
|
||||
msgid "Queued 1 watch for rechecking."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py:257
|
||||
#: changedetectionio/blueprint/ui/__init__.py:283
|
||||
#, python-brace-format
|
||||
msgid "Queued {} watches for rechecking."
|
||||
msgstr ""
|
||||
msgstr "{}개의 감시 항목을 재확인 대기열에 추가했습니다."
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py:259
|
||||
msgid "No watches available to recheck."
|
||||
@@ -2194,7 +2199,7 @@ msgid "IMPORT"
|
||||
msgstr "가져오기"
|
||||
|
||||
#: changedetectionio/templates/base.html:86
|
||||
msgid "BACKUPS"
|
||||
msgid "Backups"
|
||||
msgstr "백업"
|
||||
|
||||
#: changedetectionio/templates/base.html:90
|
||||
|
||||
@@ -770,12 +770,17 @@ msgstr ""
|
||||
msgid "Password protection removed."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:98
|
||||
#: changedetectionio/blueprint/settings/__init__.py:92
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:104
|
||||
#, python-brace-format
|
||||
msgid "Worker count adjusted: {}"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:100
|
||||
#: changedetectionio/blueprint/settings/__init__.py:106
|
||||
msgid "Dynamic worker adjustment not supported for sync workers"
|
||||
msgstr ""
|
||||
|
||||
@@ -1143,7 +1148,7 @@ msgstr ""
|
||||
msgid "Queued 1 watch for rechecking."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py:257
|
||||
#: changedetectionio/blueprint/ui/__init__.py:283
|
||||
#, python-brace-format
|
||||
msgid "Queued {} watches for rechecking."
|
||||
msgstr ""
|
||||
@@ -2211,10 +2216,6 @@ msgstr ""
|
||||
msgid "IMPORT"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/base.html:86
|
||||
msgid "BACKUPS"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/base.html:90
|
||||
msgid "EDIT"
|
||||
msgstr ""
|
||||
|
||||
Binary file not shown.
@@ -757,12 +757,17 @@ msgstr "导入"
|
||||
msgid "Password protection removed."
|
||||
msgstr "已移除密码保护。"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:98
|
||||
#: changedetectionio/blueprint/settings/__init__.py:92
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr "警告:工作线程数({})接近或超过可用CPU核心数({})"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:104
|
||||
#, python-brace-format
|
||||
msgid "Worker count adjusted: {}"
|
||||
msgstr "工作线程数已调整:{}"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:100
|
||||
#: changedetectionio/blueprint/settings/__init__.py:106
|
||||
msgid "Dynamic worker adjustment not supported for sync workers"
|
||||
msgstr "同步工作线程不支持动态调整"
|
||||
|
||||
@@ -1123,7 +1128,7 @@ msgstr "已克隆,正在编辑新的监控项。"
|
||||
msgid "Queued 1 watch for rechecking."
|
||||
msgstr "已将 1 个监控项加入重新检查队列。"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py:257
|
||||
#: changedetectionio/blueprint/ui/__init__.py:283
|
||||
#, python-brace-format
|
||||
msgid "Queued {} watches for rechecking."
|
||||
msgstr "已将 {} 个监控项加入重新检查队列。"
|
||||
@@ -2148,7 +2153,7 @@ msgid "IMPORT"
|
||||
msgstr "导入"
|
||||
|
||||
#: changedetectionio/templates/base.html:86
|
||||
msgid "BACKUPS"
|
||||
msgid "Backups"
|
||||
msgstr "备份"
|
||||
|
||||
#: changedetectionio/templates/base.html:90
|
||||
|
||||
Binary file not shown.
@@ -757,12 +757,17 @@ msgstr "匯入"
|
||||
msgid "Password protection removed."
|
||||
msgstr "密碼保護已移除。"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:98
|
||||
#: changedetectionio/blueprint/settings/__init__.py:92
|
||||
#, python-brace-format
|
||||
msgid "Warning: Worker count ({}) is close to or exceeds available CPU cores ({})"
|
||||
msgstr "警告:工作程式數量({})接近或超過可用CPU核心數({})"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:104
|
||||
#, python-brace-format
|
||||
msgid "Worker count adjusted: {}"
|
||||
msgstr "工作程序數量已調整:{}"
|
||||
|
||||
#: changedetectionio/blueprint/settings/__init__.py:100
|
||||
#: changedetectionio/blueprint/settings/__init__.py:106
|
||||
msgid "Dynamic worker adjustment not supported for sync workers"
|
||||
msgstr "同步工作程序不支援動態調整"
|
||||
|
||||
@@ -1123,7 +1128,7 @@ msgstr "已複製,您正在編輯新的監測任務。"
|
||||
msgid "Queued 1 watch for rechecking."
|
||||
msgstr "已將 1 個監測任務排入複查佇列。"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py:257
|
||||
#: changedetectionio/blueprint/ui/__init__.py:283
|
||||
#, python-brace-format
|
||||
msgid "Queued {} watches for rechecking."
|
||||
msgstr "已將 {} 個監測任務排入複查佇列。"
|
||||
@@ -2148,7 +2153,7 @@ msgid "IMPORT"
|
||||
msgstr "匯入"
|
||||
|
||||
#: changedetectionio/templates/base.html:86
|
||||
msgid "BACKUPS"
|
||||
msgid "Backups"
|
||||
msgstr "備份"
|
||||
|
||||
#: changedetectionio/templates/base.html:90
|
||||
|
||||
Reference in New Issue
Block a user