mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-11-15 18:40:23 +00:00
Scheduler+API Bug - if an invalid timezone was set (through edit of watch or API) it could have crashed the scheduler, Added timezone to the official API docs (#4350)
* Scheduler+API Bug - if an invalid timezone was set (through edit of watch or API) it could have crashed the scheduler, Added `timezone` to the official API docs * adding missing files
This commit is contained in:
1 parent
2b0c5e24e6
commit
dce6f23cf9
10 files changed
+949
-18
No files matched your search
@@ -18,6 +18,42 @@ from ..notification import valid_notification_formats
|
||||
from ..notification.handler import newline_re
|
||||
|
||||
|
||||
def validate_time_schedule_limit(json_data):
|
||||
"""
|
||||
Validate the optional timezone inside time_schedule_limit.
|
||||
|
||||
The edit form runs validateTimeZoneName on this field, but the API did not -
|
||||
the OpenAPI schema for time_schedule_limit does not declare a `timezone`
|
||||
property at all and does not set additionalProperties:false, so any string
|
||||
passed straight through and was stored.
|
||||
|
||||
That matters because the scheduler resolves it with arrow.now(tz), which
|
||||
raises on an unknown zone. Before the ticker thread was hardened, a single
|
||||
authenticated PUT with a bogus timezone stopped scheduling for EVERY watch
|
||||
on the instance. It is now contained to the one watch, but that watch would
|
||||
still silently never be checked again, so reject it at the boundary.
|
||||
|
||||
Returns None if valid, or an error message string if invalid.
|
||||
"""
|
||||
schedule = json_data.get('time_schedule_limit')
|
||||
if not isinstance(schedule, dict):
|
||||
return None
|
||||
|
||||
tz_name = schedule.get('timezone')
|
||||
if not tz_name:
|
||||
return None
|
||||
|
||||
if not isinstance(tz_name, str):
|
||||
return "time_schedule_limit.timezone must be a string IANA timezone name, e.g. 'Europe/Berlin'."
|
||||
|
||||
from zoneinfo import available_timezones
|
||||
if tz_name.strip() not in available_timezones():
|
||||
return (f"time_schedule_limit.timezone '{tz_name}' is not a valid timezone name. "
|
||||
f"Use an IANA name such as 'Europe/Berlin' or 'UTC'.")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def validate_time_between_check_required(json_data):
|
||||
"""
|
||||
Validate that at least one time interval is specified when not using default settings.
|
||||
@@ -159,6 +195,11 @@ class Watch(Resource):
|
||||
if validation_error:
|
||||
return validation_error, 400
|
||||
|
||||
# An invalid timezone here makes the watch permanently unschedulable
|
||||
validation_error = validate_time_schedule_limit(request.json)
|
||||
if validation_error:
|
||||
return validation_error, 400
|
||||
|
||||
# Validate notification_urls if provided
|
||||
if 'notification_urls' in request.json:
|
||||
from wtforms import ValidationError
|
||||
@@ -488,6 +529,11 @@ class CreateWatch(Resource):
|
||||
if validation_error:
|
||||
return validation_error, 400
|
||||
|
||||
# An invalid timezone here makes the watch permanently unschedulable
|
||||
validation_error = validate_time_schedule_limit(json_data)
|
||||
if validation_error:
|
||||
return validation_error, 400
|
||||
|
||||
# Validate notification_urls if provided
|
||||
if 'notification_urls' in json_data:
|
||||
from wtforms import ValidationError
|
||||
|
||||
@@ -8,7 +8,7 @@ from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from changedetectionio.store import ChangeDetectionStore
|
||||
from changedetectionio.auth_decorator import login_optionally_required
|
||||
from changedetectionio.time_handler import is_within_schedule
|
||||
from changedetectionio.time_handler import default_timezone_name, is_within_schedule
|
||||
from changedetectionio import worker_pool
|
||||
from changedetectionio.llm.evaluator import get_llm_config as _get_llm_config
|
||||
|
||||
@@ -253,13 +253,14 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
watch = datastore.data['watching'].get(uuid)
|
||||
|
||||
if watch.get('time_between_check_use_default'):
|
||||
time_schedule_limit = datastore.data['settings']['requests'].get('time_schedule_limit', {})
|
||||
time_schedule_limit = datastore.data['settings']['requests'].get('time_schedule_limit') or {}
|
||||
else:
|
||||
time_schedule_limit = watch.get('time_schedule_limit')
|
||||
time_schedule_limit = watch.get('time_schedule_limit') or {}
|
||||
|
||||
tz_name = time_schedule_limit.get('timezone')
|
||||
if not tz_name:
|
||||
tz_name = datastore.data['settings']['application'].get('scheduler_timezone_default', os.getenv('TZ', 'UTC').strip())
|
||||
tz_name = default_timezone_name(
|
||||
time_schedule_limit.get('timezone')
|
||||
or datastore.data['settings']['application'].get('scheduler_timezone_default')
|
||||
)
|
||||
|
||||
if time_schedule_limit and time_schedule_limit.get('enabled'):
|
||||
try:
|
||||
@@ -267,9 +268,12 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
default_tz=tz_name
|
||||
)
|
||||
except Exception as e:
|
||||
# Only decides whether to queue an immediate recheck — the watch is
|
||||
# already saved by this point. Returning a bare `False` from a view
|
||||
# made Flask raise TypeError and the save appeared to fail with a 500.
|
||||
logger.error(
|
||||
f"{uuid} - Recheck scheduler, error handling timezone, check skipped - TZ name '{tz_name}' - {str(e)}")
|
||||
return False
|
||||
is_in_schedule = False
|
||||
|
||||
#############################
|
||||
if not datastore.data['watching'][uuid].get('paused') and is_in_schedule:
|
||||
@@ -469,15 +473,22 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
|
||||
uuid = request.args.get('uuid','')
|
||||
if datastore.data["watching"].get(uuid):
|
||||
# Build the new list and REBIND it. Appending in place bypasses
|
||||
# watch_base.__setitem__, so the watch is never flagged as edited and
|
||||
# the "content unchanged since last check" skip stays active — the new
|
||||
# ignore_text would then not take effect until the page changed on its
|
||||
# own. Assigning the key marks the watch edited and forces reprocessing.
|
||||
ignore_text = list(datastore.data["watching"][uuid]['ignore_text'])
|
||||
if mode == 'exact':
|
||||
for l in selection.splitlines():
|
||||
datastore.data["watching"][uuid]['ignore_text'].append(l.strip())
|
||||
ignore_text.append(l.strip())
|
||||
elif mode == 'digit-regex':
|
||||
for l in selection.splitlines():
|
||||
# Replace any series of numbers with a regex
|
||||
s = re.escape(l.strip())
|
||||
s = re.sub(r'[0-9]+', r'\\d+', s)
|
||||
datastore.data["watching"][uuid]['ignore_text'].append('/' + s + '/')
|
||||
ignore_text.append('/' + s + '/')
|
||||
datastore.data["watching"][uuid]['ignore_text'] = ignore_text
|
||||
|
||||
# Save the updated ignore_text
|
||||
datastore.data["watching"][uuid].commit()
|
||||
|
||||
@@ -44,7 +44,8 @@ from changedetectionio import __version__
|
||||
from changedetectionio import queuedWatchMetaData
|
||||
from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, WatchHistoryDiff, CreateWatch, Import, SystemInfo, Tag, Tags, Notifications, WatchFavicon, Spec
|
||||
from changedetectionio.api.Search import Search
|
||||
from .time_handler import is_within_schedule
|
||||
from .time_handler import default_timezone_name, is_within_schedule
|
||||
from .thread_supervisor import start_supervised_thread
|
||||
from changedetectionio.languages import get_available_languages, get_language_codes, get_flag_for_locale, get_timeago_locale
|
||||
from changedetectionio.favicon_utils import get_favicon_mime_type
|
||||
|
||||
@@ -1115,7 +1116,19 @@ def changedetection_app(config=None, datastore_o=None):
|
||||
batch_mode = app.config.get('batch_mode', False)
|
||||
if not batch_mode:
|
||||
# @todo handle ctrl break
|
||||
ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks, daemon=True, name="TickerThread-ScheduleChecker").start()
|
||||
# Supervised: if the ticker ever returns or raises it is logged CRITICAL and
|
||||
# restarted. A bare Thread cannot be restarted once its target returns, and a
|
||||
# dead ticker means no watch is ever checked again while the process keeps
|
||||
# looking healthy. Note this keeps a real Thread handle - Thread(...).start()
|
||||
# returns None, so the old assignment left `ticker_thread` permanently None.
|
||||
ticker_thread = start_supervised_thread(
|
||||
target=ticker_thread_check_time_launch_checks,
|
||||
name="TickerThread-ScheduleChecker",
|
||||
exit_event=app.config.exit,
|
||||
# sigshutdown_handler() sets both of these; check both so a restart
|
||||
# can never race an in-progress shutdown.
|
||||
is_shutting_down=lambda: bool(getattr(datastore, 'stop_thread', False)),
|
||||
)
|
||||
|
||||
# Start configurable number of notification workers (default 1)
|
||||
notification_workers = int(os.getenv("NOTIFICATION_WORKERS", "1"))
|
||||
@@ -1315,7 +1328,9 @@ def ticker_thread_check_time_launch_checks():
|
||||
time_schedule_limit = watch.get('time_schedule_limit')
|
||||
scheduler_source = 'watch'
|
||||
|
||||
tz_name = datastore.data['settings']['application'].get('scheduler_timezone_default', os.getenv('TZ', 'UTC').strip())
|
||||
tz_name = default_timezone_name(
|
||||
datastore.data['settings']['application'].get('scheduler_timezone_default')
|
||||
)
|
||||
|
||||
if time_schedule_limit and time_schedule_limit.get('enabled'):
|
||||
logger.trace(f"{uuid} Time scheduler - Using scheduler settings from {scheduler_source}")
|
||||
@@ -1327,9 +1342,13 @@ def ticker_thread_check_time_launch_checks():
|
||||
logger.trace(f"{uuid} Time scheduler - not within schedule skipping.")
|
||||
continue
|
||||
except Exception as e:
|
||||
# `continue`, never `return` — this runs inside the ticker thread's
|
||||
# main `while not exit.is_set()` loop, so returning here killed the
|
||||
# scheduler outright and no watch was ever checked again until
|
||||
# restart. One watch with a bad schedule must not stop the others.
|
||||
logger.error(
|
||||
f"{uuid} - Recheck scheduler, error handling timezone, check skipped - TZ name '{tz_name}' - {str(e)}")
|
||||
return False
|
||||
continue
|
||||
|
||||
# If they supplied an individual entry minutes to threshold.
|
||||
threshold = recheck_time_system_seconds if watch.get('time_between_check_use_default') else watch.threshold_seconds()
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Regression tests for two scheduler/timezone bugs.
|
||||
|
||||
BUG 1 - edit page returned HTTP 500
|
||||
ui/edit.py resolved the default timezone with
|
||||
.get('scheduler_timezone_default', os.getenv('TZ', 'UTC'))
|
||||
but App.py initialises that key to None, so the key EXISTS and dict.get()
|
||||
never uses its fallback. None reached is_within_schedule(), which does
|
||||
`tz_name.strip()` -> AttributeError -> the handler did `return False` -> Flask
|
||||
raised TypeError ("view function did not return a valid response ... it was a
|
||||
bool") -> HTTP 500. The watch was actually saved, so the user saw a 500 on a
|
||||
successful save.
|
||||
|
||||
BUG 2 - ticker thread died silently
|
||||
Same root cause, but flask_app.py's `return False` was inside the ticker
|
||||
thread's main loop. One watch with an unresolvable timezone ended the thread
|
||||
and NO watch was ever checked again until restart, with the process still up.
|
||||
|
||||
Run: pytest changedetectionio/tests/test_scheduler_timezone_regressions.py
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from flask import url_for
|
||||
|
||||
from .util import live_server_setup, wait_for_all_checks, set_original_response
|
||||
|
||||
|
||||
DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
||||
|
||||
|
||||
def _schedule_form(url, enabled_days=('monday',)):
|
||||
data = {
|
||||
"url": url,
|
||||
"fetch_backend": "html_requests",
|
||||
"time_between_check_use_default": "",
|
||||
"time_between_check-seconds": 1,
|
||||
"time_schedule_limit-enabled": 'y',
|
||||
}
|
||||
for day in DAYS:
|
||||
data[f"time_schedule_limit-{day}-start_time"] = "00:00"
|
||||
data[f"time_schedule_limit-{day}-duration-hours"] = 24
|
||||
data[f"time_schedule_limit-{day}-duration-minutes"] = 0
|
||||
if day in enabled_days:
|
||||
data[f"time_schedule_limit-{day}-enabled"] = 'y'
|
||||
return data
|
||||
|
||||
|
||||
def _full_schedule(enabled=True, timezone=None):
|
||||
sched = {'enabled': True}
|
||||
for day in DAYS:
|
||||
sched[day] = {'enabled': enabled, 'start_time': '00:00',
|
||||
'duration': {'hours': '24', 'minutes': '00'}}
|
||||
if timezone is not None:
|
||||
sched['timezone'] = timezone
|
||||
return sched
|
||||
|
||||
|
||||
def test_edit_page_saves_when_no_default_timezone_configured(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""BUG 1: enabling the scheduler with scheduler_timezone_default unset 500'd."""
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
|
||||
# This is the default state from model/App.py - key present, value None.
|
||||
datastore.data['settings']['application']['scheduler_timezone_default'] = None
|
||||
|
||||
uuid = datastore.add_watch(url=test_url)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.edit_page", uuid=uuid),
|
||||
data=_schedule_form(test_url),
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
assert res.status_code == 200, f"expected 200, got {res.status_code}"
|
||||
assert b"Internal Server Error" not in res.data
|
||||
assert b"Updated watch." in res.data
|
||||
|
||||
# and the schedule really was saved
|
||||
assert datastore.data['watching'][uuid]['time_schedule_limit']['enabled'] is True
|
||||
|
||||
|
||||
def test_edit_page_saves_with_unresolvable_timezone(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""A garbage timezone must degrade to 'skip the recheck', not 500."""
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
datastore.data['settings']['application']['scheduler_timezone_default'] = 'Not/ARealZone'
|
||||
|
||||
uuid = datastore.add_watch(url=test_url)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.edit_page", uuid=uuid),
|
||||
data=_schedule_form(test_url),
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert res.status_code == 200, f"expected 200, got {res.status_code}"
|
||||
assert b"Updated watch." in res.data
|
||||
|
||||
|
||||
def test_ticker_survives_a_watch_with_a_broken_timezone(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
BUG 2: one bad watch used to kill the ticker thread, stopping every other
|
||||
watch. Prove a healthy watch still gets checked alongside a broken one.
|
||||
"""
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
datastore.data['settings']['application']['scheduler_timezone_default'] = None
|
||||
|
||||
broken_uuid = datastore.add_watch(url=test_url)
|
||||
healthy_uuid = datastore.add_watch(url=test_url)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
# Broken watch: scheduler on, timezone that arrow cannot resolve
|
||||
datastore.data['watching'][broken_uuid]['time_between_check_use_default'] = False
|
||||
datastore.data['watching'][broken_uuid]['time_schedule_limit'] = _full_schedule(
|
||||
timezone='Not/ARealZone'
|
||||
)
|
||||
|
||||
# Healthy watch: no schedule limit, short interval so the ticker must pick it up
|
||||
datastore.data['watching'][healthy_uuid]['time_between_check_use_default'] = False
|
||||
datastore.data['watching'][healthy_uuid]['time_between_check'] = {
|
||||
'weeks': None, 'days': None, 'hours': None, 'minutes': None, 'seconds': 2
|
||||
}
|
||||
before = datastore.data['watching'][healthy_uuid]['last_checked']
|
||||
|
||||
# Give the ticker several passes over the broken watch
|
||||
deadline = time.time() + 30
|
||||
while time.time() < deadline:
|
||||
if datastore.data['watching'][healthy_uuid]['last_checked'] != before:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
|
||||
assert datastore.data['watching'][healthy_uuid]['last_checked'] != before, (
|
||||
"the healthy watch was never rechecked - the ticker thread most likely "
|
||||
"died on the broken watch's timezone"
|
||||
)
|
||||
|
||||
|
||||
def test_api_rejects_invalid_timezone_on_update(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
The edit form runs validateTimeZoneName, but the API did not - the OpenAPI
|
||||
schema for time_schedule_limit declares no `timezone` property and does not
|
||||
set additionalProperties:false, so any string was accepted and stored.
|
||||
A bogus zone makes arrow.now(tz) raise inside the scheduler.
|
||||
"""
|
||||
import json
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
api_key = datastore.data['settings']['application'].get('api_access_token')
|
||||
|
||||
uuid = datastore.add_watch(url=test_url)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
res = client.put(
|
||||
url_for("watch", uuid=uuid),
|
||||
headers={'x-api-key': api_key, 'content-type': 'application/json'},
|
||||
data=json.dumps({'time_schedule_limit': _full_schedule(timezone='Not/ARealZone')}),
|
||||
)
|
||||
assert res.status_code == 400, f"expected 400, got {res.status_code}: {res.data}"
|
||||
assert b'not a valid timezone' in res.data.lower()
|
||||
|
||||
stored = datastore.data['watching'][uuid]['time_schedule_limit']
|
||||
assert stored.get('timezone') != 'Not/ARealZone', "invalid timezone was persisted anyway"
|
||||
|
||||
|
||||
def test_api_accepts_valid_timezone_on_update(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""The validation must not block legitimate zones."""
|
||||
import json
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
api_key = datastore.data['settings']['application'].get('api_access_token')
|
||||
|
||||
uuid = datastore.add_watch(url=test_url)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
for tz in ('Europe/Berlin', 'UTC', 'Pacific/Kiritimati'):
|
||||
res = client.put(
|
||||
url_for("watch", uuid=uuid),
|
||||
headers={'x-api-key': api_key, 'content-type': 'application/json'},
|
||||
data=json.dumps({'time_schedule_limit': _full_schedule(timezone=tz)}),
|
||||
)
|
||||
assert res.status_code == 200, f"{tz} rejected: {res.data}"
|
||||
assert datastore.data['watching'][uuid]['time_schedule_limit']['timezone'] == tz
|
||||
|
||||
# omitting the timezone entirely stays valid
|
||||
res = client.put(
|
||||
url_for("watch", uuid=uuid),
|
||||
headers={'x-api-key': api_key, 'content-type': 'application/json'},
|
||||
data=json.dumps({'time_schedule_limit': _full_schedule()}),
|
||||
)
|
||||
assert res.status_code == 200, res.data
|
||||
|
||||
|
||||
def test_api_rejects_invalid_timezone_on_create(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Same guard on POST /watch, otherwise it is just a different door."""
|
||||
import json
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
api_key = datastore.data['settings']['application'].get('api_access_token')
|
||||
|
||||
res = client.post(
|
||||
url_for("createwatch"),
|
||||
headers={'x-api-key': api_key, 'content-type': 'application/json'},
|
||||
data=json.dumps({'url': test_url,
|
||||
'time_schedule_limit': _full_schedule(timezone='Bogus/Zone')}),
|
||||
)
|
||||
assert res.status_code == 400, f"expected 400, got {res.status_code}: {res.data}"
|
||||
assert b'not a valid timezone' in res.data.lower()
|
||||
|
||||
|
||||
def test_ignore_text_via_selection_marks_watch_edited(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
The selection UI appended to ignore_text in place. That bypasses
|
||||
watch_base.__setitem__, so was_edited stayed False and the
|
||||
'content unchanged since last check' skip stayed active - the new
|
||||
ignore_text would not take effect until the page changed on its own.
|
||||
"""
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
|
||||
uuid = datastore.add_watch(url=test_url)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
watch = datastore.data['watching'][uuid]
|
||||
watch.reset_watch_edited_flag()
|
||||
assert watch.was_edited is False, "precondition: flag should start clear"
|
||||
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.highlight_submit_ignore_url", uuid=uuid),
|
||||
data={'mode': 'exact', 'selection': 'Which is across multiple lines'},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
watch = datastore.data['watching'][uuid]
|
||||
assert 'Which is across multiple lines' in watch['ignore_text'], "the text was not stored"
|
||||
assert watch.was_edited is True, (
|
||||
"watch was not flagged as edited - the new ignore_text will not be applied "
|
||||
"until the page content changes on its own"
|
||||
)
|
||||
|
||||
|
||||
def test_ignore_text_digit_regex_marks_watch_edited(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Same for the digit-regex branch."""
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
|
||||
uuid = datastore.add_watch(url=test_url)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
datastore.data['watching'][uuid].reset_watch_edited_flag()
|
||||
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.highlight_submit_ignore_url", uuid=uuid),
|
||||
data={'mode': 'digit-regex', 'selection': 'Updated 1234 times'},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
watch = datastore.data['watching'][uuid]
|
||||
assert any(t.startswith('/') and t.endswith('/') for t in watch['ignore_text']), \
|
||||
f"expected a regex entry, got {watch['ignore_text']}"
|
||||
assert watch.was_edited is True
|
||||
|
||||
|
||||
def test_ignore_text_appends_to_existing(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Rebinding must preserve entries already there, not replace them."""
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
|
||||
uuid = datastore.add_watch(url=test_url)
|
||||
wait_for_all_checks(client)
|
||||
datastore.data['watching'][uuid]['ignore_text'] = ['already here']
|
||||
|
||||
client.post(
|
||||
url_for("ui.ui_edit.highlight_submit_ignore_url", uuid=uuid),
|
||||
data={'mode': 'exact', 'selection': 'line one\nline two'},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
ignore_text = datastore.data['watching'][uuid]['ignore_text']
|
||||
assert 'already here' in ignore_text, "existing entries were lost"
|
||||
assert 'line one' in ignore_text
|
||||
assert 'line two' in ignore_text
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Regression tests for the scheduler's default-timezone resolution.
|
||||
|
||||
The bug: model/App.py initialises 'scheduler_timezone_default' to None, so the
|
||||
key ALWAYS EXISTS with a None value. Call sites used
|
||||
|
||||
settings['application'].get('scheduler_timezone_default', os.getenv('TZ', 'UTC'))
|
||||
|
||||
which never reaches its fallback, because dict.get() only substitutes the
|
||||
default when the key is ABSENT - not when its value is None. The resulting None
|
||||
flowed into is_within_schedule(), which does `tz_name.strip()` and raised
|
||||
AttributeError. That took out the edit page (HTTP 500) and, worse, the ticker
|
||||
thread (which did `return False` inside its main loop and stopped scheduling
|
||||
every watch until restart).
|
||||
|
||||
Run: python3 -m unittest changedetectionio.tests.unit.test_scheduler_timezone_resolution
|
||||
"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from changedetectionio.time_handler import default_timezone_name, is_within_schedule
|
||||
|
||||
|
||||
class TestDefaultTimezoneName(unittest.TestCase):
|
||||
|
||||
def test_none_falls_back_to_utc(self):
|
||||
"""The exact bug: a present-but-None setting must not stay None."""
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop('TZ', None)
|
||||
self.assertEqual(default_timezone_name(None), 'UTC')
|
||||
|
||||
def test_empty_string_falls_back(self):
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop('TZ', None)
|
||||
self.assertEqual(default_timezone_name(''), 'UTC')
|
||||
self.assertEqual(default_timezone_name(' '), 'UTC')
|
||||
|
||||
def test_configured_value_wins(self):
|
||||
self.assertEqual(default_timezone_name('Pacific/Kiritimati'), 'Pacific/Kiritimati')
|
||||
|
||||
def test_configured_value_is_stripped(self):
|
||||
self.assertEqual(default_timezone_name(' Europe/Berlin '), 'Europe/Berlin')
|
||||
|
||||
def test_tz_env_used_when_unconfigured(self):
|
||||
with patch.dict(os.environ, {'TZ': 'Asia/Tokyo'}):
|
||||
self.assertEqual(default_timezone_name(None), 'Asia/Tokyo')
|
||||
|
||||
def test_configured_beats_tz_env(self):
|
||||
with patch.dict(os.environ, {'TZ': 'Asia/Tokyo'}):
|
||||
self.assertEqual(default_timezone_name('Europe/Berlin'), 'Europe/Berlin')
|
||||
|
||||
def test_never_returns_falsy(self):
|
||||
with patch.dict(os.environ, {'TZ': ' '}):
|
||||
self.assertEqual(default_timezone_name(None), 'UTC')
|
||||
for value in (None, '', ' ', 0, False, []):
|
||||
self.assertTrue(default_timezone_name(value),
|
||||
f"default_timezone_name({value!r}) must never be falsy")
|
||||
|
||||
def test_reproduces_the_original_crash(self):
|
||||
"""Passing the un-resolved None straight through still raises - proving
|
||||
the resolver is what prevents it, not luck elsewhere."""
|
||||
schedule = {
|
||||
'enabled': True,
|
||||
'monday': {'enabled': True, 'start_time': '00:00',
|
||||
'duration': {'hours': '24', 'minutes': '00'}},
|
||||
}
|
||||
with self.assertRaises(AttributeError):
|
||||
is_within_schedule(time_schedule_limit=schedule, default_tz=None)
|
||||
|
||||
# ...and does not raise once resolved
|
||||
full = {'enabled': True}
|
||||
for day in ('monday', 'tuesday', 'wednesday', 'thursday',
|
||||
'friday', 'saturday', 'sunday'):
|
||||
full[day] = {'enabled': True, 'start_time': '00:00',
|
||||
'duration': {'hours': '24', 'minutes': '00'}}
|
||||
self.assertTrue(
|
||||
is_within_schedule(time_schedule_limit=full,
|
||||
default_tz=default_timezone_name(None))
|
||||
)
|
||||
|
||||
|
||||
class TestApiTimezoneValidation(unittest.TestCase):
|
||||
"""
|
||||
The edit form runs validateTimeZoneName on time_schedule_limit.timezone, but
|
||||
the API did not: the OpenAPI schema declared no `timezone` property and did
|
||||
not set additionalProperties:false, so any string was accepted and stored.
|
||||
"""
|
||||
|
||||
DAYS = ('monday', 'tuesday', 'wednesday', 'thursday',
|
||||
'friday', 'saturday', 'sunday')
|
||||
|
||||
def _payload(self, timezone=..., ):
|
||||
sched = {'enabled': True}
|
||||
for day in self.DAYS:
|
||||
sched[day] = {'enabled': True, 'start_time': '00:00',
|
||||
'duration': {'hours': '24', 'minutes': '00'}}
|
||||
if timezone is not ...:
|
||||
sched['timezone'] = timezone
|
||||
return {'time_schedule_limit': sched}
|
||||
|
||||
def setUp(self):
|
||||
from changedetectionio.api.Watch import validate_time_schedule_limit
|
||||
self.validate = validate_time_schedule_limit
|
||||
|
||||
def test_valid_zones_accepted(self):
|
||||
for tz in ('UTC', 'Europe/Berlin', 'America/Los_Angeles', 'Pacific/Kiritimati'):
|
||||
self.assertIsNone(self.validate(self._payload(tz)), f"{tz} should be valid")
|
||||
|
||||
def test_absent_or_empty_timezone_is_fine(self):
|
||||
self.assertIsNone(self.validate(self._payload())) # key absent
|
||||
self.assertIsNone(self.validate(self._payload(''))) # empty
|
||||
self.assertIsNone(self.validate(self._payload(None))) # explicit null
|
||||
|
||||
def test_unknown_zone_rejected(self):
|
||||
err = self.validate(self._payload('Not/ARealZone'))
|
||||
self.assertIsNotNone(err)
|
||||
self.assertIn('Not/ARealZone', err)
|
||||
self.assertIn('not a valid timezone', err.lower())
|
||||
|
||||
def test_case_sensitive_like_the_form(self):
|
||||
"""'utc' is not an IANA name; the form rejects it, so must the API."""
|
||||
self.assertIsNotNone(self.validate(self._payload('utc')))
|
||||
|
||||
def test_non_string_rejected(self):
|
||||
for bad in (123, 12.5, True, ['UTC'], {'name': 'UTC'}):
|
||||
self.assertIsNotNone(self.validate(self._payload(bad)),
|
||||
f"{bad!r} should be rejected")
|
||||
|
||||
def test_no_schedule_key_at_all(self):
|
||||
self.assertIsNone(self.validate({}))
|
||||
self.assertIsNone(self.validate({'time_schedule_limit': None}))
|
||||
self.assertIsNone(self.validate({'url': 'https://example.com'}))
|
||||
|
||||
def test_non_dict_schedule_is_left_to_openapi(self):
|
||||
"""Structural type errors are the OpenAPI layer's job, not ours."""
|
||||
self.assertIsNone(self.validate({'time_schedule_limit': 'nope'}))
|
||||
self.assertIsNone(self.validate({'time_schedule_limit': []}))
|
||||
|
||||
def test_rejected_zone_would_have_crashed_the_scheduler(self):
|
||||
"""Tie the validation back to the actual failure it prevents."""
|
||||
payload = self._payload('Not/ARealZone')
|
||||
self.assertIsNotNone(self.validate(payload))
|
||||
with self.assertRaises(Exception):
|
||||
is_within_schedule(time_schedule_limit=payload['time_schedule_limit'],
|
||||
default_tz='UTC')
|
||||
|
||||
|
||||
class TestApiSpecDocumentsTimezone(unittest.TestCase):
|
||||
"""The field is real and settable; the published contract must say so."""
|
||||
|
||||
def test_timezone_is_declared_in_the_openapi_spec(self):
|
||||
import os
|
||||
import yaml
|
||||
|
||||
here = os.path.dirname(__file__)
|
||||
spec_path = os.path.join(here, '..', '..', '..', 'docs', 'api-spec.yaml')
|
||||
if not os.path.isfile(spec_path):
|
||||
self.skipTest("api-spec.yaml not present in this layout")
|
||||
|
||||
with open(spec_path) as f:
|
||||
spec = yaml.safe_load(f)
|
||||
|
||||
schedule = spec['components']['schemas']['WatchBase']['properties']['time_schedule_limit']
|
||||
self.assertIn('timezone', schedule['properties'],
|
||||
"time_schedule_limit.timezone is settable via the form and the "
|
||||
"API but is missing from the OpenAPI spec")
|
||||
self.assertEqual(schedule['properties']['timezone']['type'], 'string')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Tests for thread_supervisor.supervise().
|
||||
|
||||
Context: the ticker thread had `return False` buried inside its per-watch loop.
|
||||
One watch with an unresolvable timezone made the whole loop return, the thread
|
||||
ended, and no watch was ever checked again - with the process still up and
|
||||
looking healthy. threading.Thread cannot be restarted (start() twice raises
|
||||
RuntimeError), so recovery has to live inside the thread.
|
||||
|
||||
The two behaviours that matter:
|
||||
- a target that returns or raises is restarted, loudly
|
||||
- a target that exits during shutdown is NOT restarted
|
||||
|
||||
Run: python3 -m unittest changedetectionio.tests.unit.test_thread_supervisor
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from changedetectionio.thread_supervisor import supervise, start_supervised_thread
|
||||
|
||||
|
||||
class TestRestartsOnDeath(unittest.TestCase):
|
||||
|
||||
def test_target_that_returns_is_restarted(self):
|
||||
"""The exact ticker bug: a stray `return` must not end the thread."""
|
||||
exit_event = threading.Event()
|
||||
calls = []
|
||||
|
||||
def flaky():
|
||||
calls.append('run')
|
||||
if len(calls) >= 3:
|
||||
exit_event.set() # let the supervisor finish
|
||||
return # simulates `return False` in the loop
|
||||
|
||||
supervise(flaky, 'flaky', exit_event, max_backoff=0.01, healthy_after=9999)
|
||||
self.assertEqual(len(calls), 3, "target should have been restarted twice")
|
||||
|
||||
def test_target_that_raises_is_restarted(self):
|
||||
exit_event = threading.Event()
|
||||
calls = []
|
||||
|
||||
def crashy():
|
||||
calls.append('run')
|
||||
if len(calls) >= 3:
|
||||
exit_event.set()
|
||||
return
|
||||
raise RuntimeError("boom")
|
||||
|
||||
supervise(crashy, 'crashy', exit_event, max_backoff=0.01, healthy_after=9999)
|
||||
self.assertEqual(len(calls), 3)
|
||||
|
||||
def test_restart_reason_is_reported(self):
|
||||
exit_event = threading.Event()
|
||||
reasons = []
|
||||
calls = []
|
||||
|
||||
def mixed():
|
||||
calls.append('run')
|
||||
if len(calls) == 1:
|
||||
return # clean but wrong
|
||||
if len(calls) == 2:
|
||||
raise ValueError("nope") # crash
|
||||
exit_event.set()
|
||||
|
||||
supervise(mixed, 'mixed', exit_event, max_backoff=0.01, healthy_after=9999,
|
||||
_on_restart=lambda n, reason: reasons.append(reason))
|
||||
|
||||
self.assertEqual(len(reasons), 2)
|
||||
self.assertIn('returned unexpectedly', reasons[0])
|
||||
self.assertIn('ValueError: nope', reasons[1])
|
||||
|
||||
def test_backoff_grows_then_resets_after_healthy_run(self):
|
||||
exit_event = threading.Event()
|
||||
stamps = []
|
||||
|
||||
def failing():
|
||||
stamps.append(time.monotonic())
|
||||
if len(stamps) >= 4:
|
||||
exit_event.set()
|
||||
return
|
||||
raise RuntimeError("boom")
|
||||
|
||||
supervise(failing, 'failing', exit_event, max_backoff=0.08, healthy_after=9999)
|
||||
gaps = [stamps[i + 1] - stamps[i] for i in range(len(stamps) - 1)]
|
||||
# 1s doubling is capped at max_backoff, so every gap should hit the cap
|
||||
for gap in gaps:
|
||||
self.assertGreaterEqual(gap, 0.05, f"gaps={gaps} - backoff not applied")
|
||||
|
||||
def test_does_not_spin_hot(self):
|
||||
"""A target failing instantly must not be restarted in a tight loop."""
|
||||
exit_event = threading.Event()
|
||||
calls = []
|
||||
start = time.monotonic()
|
||||
|
||||
def instant():
|
||||
calls.append(1)
|
||||
if len(calls) >= 3:
|
||||
exit_event.set()
|
||||
return
|
||||
raise RuntimeError("instant")
|
||||
|
||||
supervise(instant, 'instant', exit_event, max_backoff=0.05, healthy_after=9999)
|
||||
self.assertGreater(time.monotonic() - start, 0.05,
|
||||
"supervisor restarted with no delay - would burn CPU")
|
||||
|
||||
|
||||
class TestDoesNotRestartDuringShutdown(unittest.TestCase):
|
||||
|
||||
def test_exit_event_stops_restarting(self):
|
||||
exit_event = threading.Event()
|
||||
calls = []
|
||||
|
||||
def target():
|
||||
calls.append('run')
|
||||
exit_event.set() # shutdown requested, then return normally
|
||||
|
||||
supervise(target, 'target', exit_event, max_backoff=0.01)
|
||||
self.assertEqual(len(calls), 1, "must not restart once exit_event is set")
|
||||
|
||||
def test_exit_event_set_before_start_never_runs_target(self):
|
||||
exit_event = threading.Event()
|
||||
exit_event.set()
|
||||
calls = []
|
||||
supervise(lambda: calls.append('run'), 'target', exit_event)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_secondary_shutdown_flag_stops_restarting(self):
|
||||
"""sigshutdown_handler also sets datastore.stop_thread - honour it."""
|
||||
exit_event = threading.Event()
|
||||
state = {'stop_thread': False}
|
||||
calls = []
|
||||
|
||||
def target():
|
||||
calls.append('run')
|
||||
state['stop_thread'] = True # only the secondary flag is set
|
||||
return
|
||||
|
||||
supervise(target, 'target', exit_event,
|
||||
is_shutting_down=lambda: state['stop_thread'],
|
||||
max_backoff=0.01)
|
||||
self.assertEqual(len(calls), 1,
|
||||
"must not restart when the secondary shutdown flag is set")
|
||||
self.assertFalse(exit_event.is_set(), "primary flag was never set in this test")
|
||||
|
||||
def test_raising_shutdown_predicate_does_not_block_restart(self):
|
||||
"""A broken predicate must not silently disable recovery."""
|
||||
exit_event = threading.Event()
|
||||
calls = []
|
||||
|
||||
def target():
|
||||
calls.append('run')
|
||||
if len(calls) >= 2:
|
||||
exit_event.set()
|
||||
return
|
||||
|
||||
def broken():
|
||||
raise RuntimeError("predicate is broken")
|
||||
|
||||
supervise(target, 'target', exit_event, is_shutting_down=broken,
|
||||
max_backoff=0.01, healthy_after=9999)
|
||||
self.assertEqual(len(calls), 2, "should still have restarted once")
|
||||
|
||||
|
||||
class TestStartSupervisedThread(unittest.TestCase):
|
||||
|
||||
def test_returns_a_real_thread_object(self):
|
||||
"""Thread(...).start() returns None - that is why ticker_thread was
|
||||
always None and nothing could check is_alive()."""
|
||||
exit_event = threading.Event()
|
||||
started = threading.Event()
|
||||
|
||||
def target():
|
||||
started.set()
|
||||
exit_event.wait(5)
|
||||
|
||||
t = start_supervised_thread(target, 'TestThread', exit_event)
|
||||
self.addCleanup(exit_event.set)
|
||||
|
||||
self.assertIsInstance(t, threading.Thread)
|
||||
self.assertTrue(started.wait(5), "target did not run")
|
||||
self.assertTrue(t.is_alive())
|
||||
self.assertTrue(t.daemon)
|
||||
self.assertEqual(t.name, 'TestThread')
|
||||
|
||||
exit_event.set()
|
||||
t.join(timeout=5)
|
||||
self.assertFalse(t.is_alive(), "thread did not exit on shutdown")
|
||||
|
||||
def test_thread_survives_a_dying_target(self):
|
||||
"""End to end: the thread outlives a target that keeps returning."""
|
||||
exit_event = threading.Event()
|
||||
calls = []
|
||||
done = threading.Event()
|
||||
|
||||
def target():
|
||||
calls.append(1)
|
||||
if len(calls) >= 3:
|
||||
done.set()
|
||||
exit_event.wait(5)
|
||||
return
|
||||
|
||||
t = start_supervised_thread(target, 'Dying', exit_event, max_backoff=0.01,
|
||||
healthy_after=9999)
|
||||
self.addCleanup(exit_event.set)
|
||||
|
||||
self.assertTrue(done.wait(10), f"target was not restarted (calls={len(calls)})")
|
||||
self.assertTrue(t.is_alive(), "thread died despite supervision")
|
||||
exit_event.set()
|
||||
t.join(timeout=5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Keep critical background threads alive.
|
||||
|
||||
A threading.Thread cannot be restarted. Once its target returns, the thread is
|
||||
finished and calling start() again raises RuntimeError("threads can only be
|
||||
started once"). There is no built-in "respawn on death" option. So recovery has
|
||||
to be built into the target itself.
|
||||
|
||||
This matters because a background loop can die in two ways that look identical
|
||||
from outside, and both are silent:
|
||||
|
||||
- it raises, and the traceback goes nowhere because nobody joins a daemon thread
|
||||
- it simply `return`s, e.g. a stray `return False` deep inside the loop body
|
||||
|
||||
The second is how the ticker thread stopped scheduling every watch in the app:
|
||||
one watch with an unresolvable timezone hit `return False`, the whole loop
|
||||
exited, and no watch was ever checked again until the process restarted. Nothing
|
||||
was logged above ERROR, and the process stayed up and healthy-looking.
|
||||
|
||||
supervise() wraps a target so that any exit which is NOT a real shutdown is
|
||||
logged CRITICAL and the target is re-entered, with exponential backoff so a
|
||||
target that fails instantly cannot spin the CPU.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from loguru import logger
|
||||
|
||||
# Back off 1, 2, 4 ... up to this many seconds between restarts.
|
||||
DEFAULT_MAX_BACKOFF_SECONDS = 60
|
||||
|
||||
# A target that stayed up at least this long is considered to have recovered,
|
||||
# so its backoff resets rather than continuing to grow.
|
||||
HEALTHY_RUNTIME_SECONDS = 60
|
||||
|
||||
|
||||
def supervise(target, name, exit_event, is_shutting_down=None,
|
||||
max_backoff=DEFAULT_MAX_BACKOFF_SECONDS,
|
||||
healthy_after=HEALTHY_RUNTIME_SECONDS,
|
||||
_on_restart=None):
|
||||
"""
|
||||
Run `target` forever, restarting it if it ever returns or raises.
|
||||
|
||||
Never restarts once shutdown has begun - an exiting target is expected then,
|
||||
and respawning it would fight the shutdown path and delay process exit.
|
||||
|
||||
Args:
|
||||
target: zero-arg callable expected to loop until shutdown
|
||||
name: thread name, used in log messages
|
||||
exit_event: threading.Event - the primary shutdown signal
|
||||
is_shutting_down: optional extra zero-arg predicate returning True during
|
||||
shutdown. sigshutdown_handler() sets both app.config.exit
|
||||
and datastore.stop_thread, so pass the latter here to be
|
||||
certain a restart can never race a shutdown.
|
||||
max_backoff: ceiling for the restart delay, in seconds
|
||||
healthy_after: runtime after which the backoff resets to 1s
|
||||
_on_restart: test hook, called with (restart_count, reason) before each retry
|
||||
|
||||
Returns:
|
||||
None - only once shutdown is signalled
|
||||
"""
|
||||
def stopping():
|
||||
if exit_event.is_set():
|
||||
return True
|
||||
if is_shutting_down is not None:
|
||||
try:
|
||||
return bool(is_shutting_down())
|
||||
except Exception:
|
||||
# A broken predicate must not keep a healthy thread from restarting
|
||||
return False
|
||||
return False
|
||||
|
||||
backoff = 1
|
||||
restarts = 0
|
||||
|
||||
while not stopping():
|
||||
started = time.monotonic()
|
||||
reason = None
|
||||
|
||||
try:
|
||||
target()
|
||||
# Falling out of the target is only legitimate during shutdown.
|
||||
if stopping():
|
||||
break
|
||||
reason = "returned unexpectedly (a stray `return` inside its loop?)"
|
||||
logger.critical(
|
||||
f"{name} {reason} - this thread must run until shutdown. Restarting it."
|
||||
)
|
||||
except Exception as e:
|
||||
if stopping():
|
||||
break
|
||||
reason = f"crashed: {type(e).__name__}: {e}"
|
||||
logger.opt(exception=True).critical(f"{name} {reason} - restarting it.")
|
||||
|
||||
ran_for = time.monotonic() - started
|
||||
backoff = 1 if ran_for >= healthy_after else min(backoff * 2, max_backoff)
|
||||
restarts += 1
|
||||
|
||||
if _on_restart:
|
||||
_on_restart(restarts, reason)
|
||||
|
||||
logger.critical(
|
||||
f"{name} restart #{restarts} in {backoff}s (previous run lasted {ran_for:.1f}s)"
|
||||
)
|
||||
# wait() returns True immediately once shutdown is requested
|
||||
if exit_event.wait(backoff) or stopping():
|
||||
break
|
||||
|
||||
logger.info(f"{name} supervisor exiting - shutdown requested "
|
||||
f"(after {restarts} restart(s))")
|
||||
|
||||
|
||||
def start_supervised_thread(target, name, exit_event, is_shutting_down=None, **kwargs):
|
||||
"""
|
||||
Start `target` in a daemon thread wrapped in supervise().
|
||||
|
||||
Returns:
|
||||
threading.Thread: the started thread (unlike Thread(...).start(), which
|
||||
returns None - assigning that to a module global is why nothing could
|
||||
ever check the ticker thread's is_alive()).
|
||||
"""
|
||||
import threading
|
||||
|
||||
thread = threading.Thread(
|
||||
target=supervise,
|
||||
args=(target, name, exit_event),
|
||||
kwargs={'is_shutting_down': is_shutting_down, **kwargs},
|
||||
daemon=True,
|
||||
name=name,
|
||||
)
|
||||
thread.start()
|
||||
return thread
|
||||
@@ -1,9 +1,32 @@
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
import arrow
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
def default_timezone_name(configured=None):
|
||||
"""
|
||||
Resolve the scheduler's default IANA timezone name. Never returns None/''.
|
||||
|
||||
`scheduler_timezone_default` is initialised to None in model/App.py, so the
|
||||
key always EXISTS with a None value. That makes the common idiom
|
||||
|
||||
settings['application'].get('scheduler_timezone_default', os.getenv('TZ', 'UTC'))
|
||||
|
||||
silently return None - dict.get() only falls back when the key is absent,
|
||||
not when its value is None. The None then reaches is_within_schedule(),
|
||||
which does `tz_name.strip()` and raises AttributeError.
|
||||
|
||||
Args:
|
||||
configured: the stored setting value (may be None or empty)
|
||||
|
||||
Returns:
|
||||
str: a non-empty timezone name, falling back to $TZ then 'UTC'
|
||||
"""
|
||||
return (configured or os.getenv('TZ') or 'UTC').strip() or 'UTC'
|
||||
|
||||
|
||||
class Weekday(IntEnum):
|
||||
"""Enumeration for days of the week."""
|
||||
Monday = 0
|
||||
|
||||
@@ -569,6 +569,17 @@ components:
|
||||
$ref: '#/components/schemas/DaySchedule'
|
||||
sunday:
|
||||
$ref: '#/components/schemas/DaySchedule'
|
||||
timezone:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
Optional IANA timezone name the schedule is evaluated in, e.g.
|
||||
"America/Los_Angeles". When empty or omitted, the global
|
||||
scheduler_timezone_default is used, falling back to $TZ then UTC.
|
||||
Must be a name recognised by the server's timezone database -
|
||||
an unknown value is rejected with HTTP 400, because the scheduler
|
||||
cannot resolve it and the watch would never be checked.
|
||||
example: America/Los_Angeles
|
||||
|
||||
# Conditions (advanced logic)
|
||||
conditions:
|
||||
|
||||
@@ -659,7 +659,7 @@ notification preferences, and content filtering options.</p>
|
||||
" class="sc-eVqvcJ sc-fszimp kIppRw drqpJr"><p>Custom server</p>
|
||||
</div><div tabindex="0" role="button"><div class="sc-xKhEK okJpy"><span>{protocol}://{host}/api/v1</span>/watch/{uuid}</div></div></div></div></div><div><h3 class="sc-lgpSej drJHMo"> <!-- -->Request samples<!-- --> </h3><div class="sc-cOpnSz fyxuKi" data-rttabs="true"><ul class="react-tabs__tab-list" role="tablist"><li class="react-tabs__tab react-tabs__tab--selected" role="tab" id="tab_R_2abha_0" aria-selected="true" aria-disabled="false" aria-controls="panel_R_2abha_0" tabindex="0" data-rttab="true">curl</li><li class="react-tabs__tab" role="tab" id="tab_R_2abha_1" aria-selected="false" aria-disabled="false" aria-controls="panel_R_2abha_1" data-rttab="true">Python</li></ul><div class="react-tabs__tab-panel react-tabs__tab-panel--selected" role="tabpanel" id="panel_R_2abha_0" aria-labelledby="tab_R_2abha_0"><div class="sc-cdmAjP gsEOpk"><div class="sc-bbbBoY bBWkcI"><button><div class="sc-fYmhhH iNCOCX">Copy</div></button></div><pre class="sc-eVqvcJ sc-jytpVa kIppRw cCzeOT">curl <span class="token operator">-</span>X GET <span class="token string">"http://localhost:5000/api/v1/watch/095be615-a8ad-4c33-8e9c-c7612fbf6c9f"</span> \
|
||||
<span class="token operator">-</span>H <span class="token string">"x-api-key: YOUR_API_KEY"</span>
|
||||
</pre></div></div><div class="react-tabs__tab-panel" role="tabpanel" id="panel_R_2abha_1" aria-labelledby="tab_R_2abha_1"></div></div></div><div><h3 class="sc-lgpSej drJHMo"> <!-- -->Response samples<!-- --> </h3><div class="sc-cOpnSz fyxuKi" data-rttabs="true"><ul class="react-tabs__tab-list" role="tablist"><li class="tab-success react-tabs__tab--selected" role="tab" id="tab_R_2ebha_0" aria-selected="true" aria-disabled="false" aria-controls="panel_R_2ebha_0" tabindex="0" data-rttab="true">200</li><li class="tab-error" role="tab" id="tab_R_2ebha_1" aria-selected="false" aria-disabled="false" aria-controls="panel_R_2ebha_1" data-rttab="true">404</li></ul><div class="react-tabs__tab-panel react-tabs__tab-panel--selected" role="tabpanel" id="panel_R_2ebha_0" aria-labelledby="tab_R_2ebha_0"><div><div class="sc-bSFBcf iLdyBp"><span class="sc-gahYZc cXitJ">Content type</span><div class="sc-cCVJLD sc-gsJsQu dbfEBv ehbHlf"><svg class="sc-pYNGo eyTvTk" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg><select class="dropdown-select"><option value="application/json" selected="">application/json</option><option value="text/plain">text/plain</option></select><label>application/json</label></div></div><div class="sc-blIAwI eKKwxo"><div class="sc-dClGHI fdRrNy"><div class="sc-bbbBoY bBWkcI"><button><div class="sc-fYmhhH iNCOCX">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex="0" class="sc-eVqvcJ kIppRw sc-fhfEft dFvLDb"><div class="redoc-json"><code><button class="collapser" aria-label="collapse"></button><span class="token punctuation">{</span><span class="ellipsis"></span><ul class="obj collapsible"><li><div class="hoverable "><span class="property token string">"uuid"</span>: <span class="token string">"095be615-a8ad-4c33-8e9c-c7612fbf6c9f"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"date_created"</span>: <span class="token number">0</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"url"</span>: <span class="token string">"</span><a href="http://example.com">http://example.com</a><span class="token string">"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"title"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"tag"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"tags"</span>: <button class="collapser" aria-label="collapse"></button><span class="token punctuation">[</span><span class="ellipsis"></span><ul class="array collapsible"><li><div class="hoverable collapsed"><span class="token string">"string"</span></div></li></ul><span class="token punctuation">]</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"paused"</span>: <span class="token boolean">true</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"notification_muted"</span>: <span class="token boolean">true</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"method"</span>: <span class="token string">"GET"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"fetch_backend"</span>: <span class="token string">"system"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"headers"</span>: <button class="collapser" aria-label="collapse"></button><span class="token punctuation">{</span><span class="ellipsis"></span><ul class="obj collapsible"><li><div class="hoverable collapsed"><span class="property token string">"property1"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable collapsed"><span class="property token string">"property2"</span>: <span class="token string">"string"</span></div></li></ul><span class="token punctuation">}</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"body"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"proxy"</span>: <span class="token string">"string&qLine truncated
|
||||
</pre></div></div><div class="react-tabs__tab-panel" role="tabpanel" id="panel_R_2abha_1" aria-labelledby="tab_R_2abha_1"></div></div></div><div><h3 class="sc-lgpSej drJHMo"> <!-- -->Response samples<!-- --> </h3><div class="sc-cOpnSz fyxuKi" data-rttabs="true"><ul class="react-tabs__tab-list" role="tablist"><li class="tab-success react-tabs__tab--selected" role="tab" id="tab_R_2ebha_0" aria-selected="true" aria-disabled="false" aria-controls="panel_R_2ebha_0" tabindex="0" data-rttab="true">200</li><li class="tab-error" role="tab" id="tab_R_2ebha_1" aria-selected="false" aria-disabled="false" aria-controls="panel_R_2ebha_1" data-rttab="true">404</li></ul><div class="react-tabs__tab-panel react-tabs__tab-panel--selected" role="tabpanel" id="panel_R_2ebha_0" aria-labelledby="tab_R_2ebha_0"><div><div class="sc-bSFBcf iLdyBp"><span class="sc-gahYZc cXitJ">Content type</span><div class="sc-cCVJLD sc-gsJsQu dbfEBv ehbHlf"><svg class="sc-pYNGo eyTvTk" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg><select class="dropdown-select"><option value="application/json" selected="">application/json</option><option value="text/plain">text/plain</option></select><label>application/json</label></div></div><div class="sc-blIAwI eKKwxo"><div class="sc-dClGHI fdRrNy"><div class="sc-bbbBoY bBWkcI"><button><div class="sc-fYmhhH iNCOCX">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex="0" class="sc-eVqvcJ kIppRw sc-fhfEft dFvLDb"><div class="redoc-json"><code><button class="collapser" aria-label="collapse"></button><span class="token punctuation">{</span><span class="ellipsis"></span><ul class="obj collapsible"><li><div class="hoverable "><span class="property token string">"uuid"</span>: <span class="token string">"095be615-a8ad-4c33-8e9c-c7612fbf6c9f"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"date_created"</span>: <span class="token number">0</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"url"</span>: <span class="token string">"</span><a href="http://example.com">http://example.com</a><span class="token string">"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"title"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"tag"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"tags"</span>: <button class="collapser" aria-label="collapse"></button><span class="token punctuation">[</span><span class="ellipsis"></span><ul class="array collapsible"><li><div class="hoverable collapsed"><span class="token string">"string"</span></div></li></ul><span class="token punctuation">]</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"paused"</span>: <span class="token boolean">true</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"notification_muted"</span>: <span class="token boolean">true</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"method"</span>: <span class="token string">"GET"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"fetch_backend"</span>: <span class="token string">"system"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"headers"</span>: <button class="collapser" aria-label="collapse"></button><span class="token punctuation">{</span><span class="ellipsis"></span><ul class="obj collapsible"><li><div class="hoverable collapsed"><span class="property token string">"property1"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable collapsed"><span class="property token string">"property2"</span>: <span class="token string">"string"</span></div></li></ul><span class="token punctuation">}</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"body"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"proxy"</span>: <span class="token string">"string&qLine truncated
|
||||
" class="sc-eVqvcJ sc-fszimp kIppRw kbZred"><p>Update an existing web page change monitor (watch) using JSON. Accepts the same structure as returned in <a href="#operation/getWatch">get single watch information</a>.</p>
|
||||
</div></div><div class="sc-ikkVnJ deUlC"><div class="sc-hWgKua dPSGXF"><h5 class="sc-eqYatC sc-gFqXPY czjApA jCoZLr">Authorizations:</h5><svg class="sc-dntSTA FtowP" version="1.1" viewBox="0 0 24 24" x="0" xmlns="http://www.w3.org/2000/svg" y="0" aria-hidden="true"><polygon points="17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 "></polygon></svg></div><div class="sc-jBaHRL fUkQtw"><span class="sc-iVnIWt gRXavu"><span class="sc-hqtLyI hRtRoN"><i>ApiKeyAuth</i></span></span></div></div><div><h5 class="sc-eqYatC czjApA">path<!-- --> Parameters</h5><table class="sc-eqNDNG icJLQx"><tbody><tr class="last "><td kind="field" title="uuid" class="sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ"><span class="sc-hwddKA cteAyA"></span><span class="property-name">uuid</span><div class="sc-bEjUoa sc-iIvHqT lhyyLL crXmiY">required</div></td><td class="sc-gGKoUb ixGaBD"><div><div><span class="sc-bEjUoa sc-boKDdR lhyyLL jYezsP"></span><span class="sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq">string</span><span class="sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq"> <!-- --><<!-- -->uuid<!-- -->><!-- --> </span></div> <div><div html="<p>Web page change monitor (watch) unique ID</p>
|
||||
" class="sc-eVqvcJ sc-fszimp kIppRw drqpJr"><p>Web page change monitor (watch) unique ID</p>
|
||||
@@ -791,7 +791,7 @@ notification preferences, and content filtering options.</p>
|
||||
" class="sc-eVqvcJ sc-fszimp kIppRw drqpJr"><p>Production server</p>
|
||||
</div><div tabindex="0" role="button"><div class="sc-xKhEK okJpy"><span>https://yourdomain.com/api/v1</span>/watch/{uuid}</div></div></div><div class="sc-iyBeIh icOxsG"><div html="<p>Custom server</p>
|
||||
" class="sc-eVqvcJ sc-fszimp kIppRw drqpJr"><p>Custom server</p>
|
||||
</div><div tabindex="0" role="button"><div class="sc-xKhEK okJpy"><span>{protocol}://{host}/api/v1</span>/watch/{uuid}</div></div></div></div></div><div><h3 class="sc-lgpSej drJHMo"> <!-- -->Request samples<!-- --> </h3><div class="sc-cOpnSz fyxuKi" data-rttabs="true"><ul class="react-tabs__tab-list" role="tablist"><li class="react-tabs__tab react-tabs__tab--selected" role="tab" id="tab_R_2acha_0" aria-selected="true" aria-disabled="false" aria-controls="panel_R_2acha_0" tabindex="0" data-rttab="true">Payload</li><li class="react-tabs__tab" role="tab" id="tab_R_2acha_1" aria-selected="false" aria-disabled="false" aria-controls="panel_R_2acha_1" data-rttab="true">curl</li><li class="react-tabs__tab" role="tab" id="tab_R_2acha_2" aria-selected="false" aria-disabled="false" aria-controls="panel_R_2acha_2" data-rttab="true">Python</li></ul><div class="react-tabs__tab-panel react-tabs__tab-panel--selected" role="tabpanel" id="panel_R_2acha_0" aria-labelledby="tab_R_2acha_0"><div><div class="sc-bSFBcf iLdyBp"><span class="sc-gahYZc cXitJ">Content type</span><div class="sc-bAehkN iNRAJK">application/json</div></div><div class="sc-blIAwI eKKwxo"><div class="sc-dClGHI fdRrNy"><div class="sc-bbbBoY bBWkcI"><button><div class="sc-fYmhhH iNCOCX">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex="0" class="sc-eVqvcJ kIppRw sc-fhfEft dFvLDb"><div class="redoc-json"><code><button class="collapser" aria-label="collapse"></button><span class="token punctuation">{</span><span class="ellipsis"></span><ul class="obj collapsible"><li><div class="hoverable "><span class="property token string">"url"</span>: <span class="token string">"</span><a href="http://example.com">http://example.com</a><span class="token string">"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"title"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"tag"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"tags"</span>: <button class="collapser" aria-label="collapse"></button><span class="token punctuation">[</span><span class="ellipsis"></span><ul class="array collapsible"><li><div class="hoverable collapsed"><span class="token string">"string"</span></div></li></ul><span class="token punctuation">]</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"paused"</span>: <span class="token boolean">true</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"notification_muted"</span>: <span class="token boolean">true</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"method"</span>: <span class="token string">"GET"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"fetch_backend"</span>: <span class="token string">"system"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"headers"</span>: <button class="collapser" aria-label="collapse"></button><span class="token punctuation">{</span><span class="ellipsis"></span><ul class="obj collapsible"><li><div class="hoverable collapsed"><span class="property token string">"property1"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable collapsed"><span class="property token string">"property2"</span>: <span class="token string">"string"</span></div></li></ul><span class="token punctuation">}</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"body"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"proxy"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"ignore_status_codes"</span>: <span class="token boolean">true</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"webdriver_delay"</span>: <span class="token number">0</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"webdriver_js_execute_code"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoveLine truncated
|
||||
</div><div tabindex="0" role="button"><div class="sc-xKhEK okJpy"><span>{protocol}://{host}/api/v1</span>/watch/{uuid}</div></div></div></div></div><div><h3 class="sc-lgpSej drJHMo"> <!-- -->Request samples<!-- --> </h3><div class="sc-cOpnSz fyxuKi" data-rttabs="true"><ul class="react-tabs__tab-list" role="tablist"><li class="react-tabs__tab react-tabs__tab--selected" role="tab" id="tab_R_2acha_0" aria-selected="true" aria-disabled="false" aria-controls="panel_R_2acha_0" tabindex="0" data-rttab="true">Payload</li><li class="react-tabs__tab" role="tab" id="tab_R_2acha_1" aria-selected="false" aria-disabled="false" aria-controls="panel_R_2acha_1" data-rttab="true">curl</li><li class="react-tabs__tab" role="tab" id="tab_R_2acha_2" aria-selected="false" aria-disabled="false" aria-controls="panel_R_2acha_2" data-rttab="true">Python</li></ul><div class="react-tabs__tab-panel react-tabs__tab-panel--selected" role="tabpanel" id="panel_R_2acha_0" aria-labelledby="tab_R_2acha_0"><div><div class="sc-bSFBcf iLdyBp"><span class="sc-gahYZc cXitJ">Content type</span><div class="sc-bAehkN iNRAJK">application/json</div></div><div class="sc-blIAwI eKKwxo"><div class="sc-dClGHI fdRrNy"><div class="sc-bbbBoY bBWkcI"><button><div class="sc-fYmhhH iNCOCX">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex="0" class="sc-eVqvcJ kIppRw sc-fhfEft dFvLDb"><div class="redoc-json"><code><button class="collapser" aria-label="collapse"></button><span class="token punctuation">{</span><span class="ellipsis"></span><ul class="obj collapsible"><li><div class="hoverable "><span class="property token string">"url"</span>: <span class="token string">"</span><a href="http://example.com">http://example.com</a><span class="token string">"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"title"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"tag"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"tags"</span>: <button class="collapser" aria-label="collapse"></button><span class="token punctuation">[</span><span class="ellipsis"></span><ul class="array collapsible"><li><div class="hoverable collapsed"><span class="token string">"string"</span></div></li></ul><span class="token punctuation">]</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"paused"</span>: <span class="token boolean">true</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"notification_muted"</span>: <span class="token boolean">true</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"method"</span>: <span class="token string">"GET"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"fetch_backend"</span>: <span class="token string">"system"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"headers"</span>: <button class="collapser" aria-label="collapse"></button><span class="token punctuation">{</span><span class="ellipsis"></span><ul class="obj collapsible"><li><div class="hoverable collapsed"><span class="property token string">"property1"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable collapsed"><span class="property token string">"property2"</span>: <span class="token string">"string"</span></div></li></ul><span class="token punctuation">}</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"body"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"proxy"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"ignore_status_codes"</span>: <span class="token boolean">true</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"webdriver_delay"</span>: <span class="token number">0</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"webdriver_js_execute_code"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoveLine truncated
|
||||
" class="sc-eVqvcJ sc-fszimp kIppRw kbZred"><p>Delete a web page change monitor (watch) and all related history</p>
|
||||
</div></div><div class="sc-ikkVnJ deUlC"><div class="sc-hWgKua dPSGXF"><h5 class="sc-eqYatC sc-gFqXPY czjApA jCoZLr">Authorizations:</h5><svg class="sc-dntSTA FtowP" version="1.1" viewBox="0 0 24 24" x="0" xmlns="http://www.w3.org/2000/svg" y="0" aria-hidden="true"><polygon points="17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 "></polygon></svg></div><div class="sc-jBaHRL fUkQtw"><span class="sc-iVnIWt gRXavu"><span class="sc-hqtLyI hRtRoN"><i>ApiKeyAuth</i></span></span></div></div><div><h5 class="sc-eqYatC czjApA">path<!-- --> Parameters</h5><table class="sc-eqNDNG icJLQx"><tbody><tr class="last "><td kind="field" title="uuid" class="sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ"><span class="sc-hwddKA cteAyA"></span><span class="property-name">uuid</span><div class="sc-bEjUoa sc-iIvHqT lhyyLL crXmiY">required</div></td><td class="sc-gGKoUb ixGaBD"><div><div><span class="sc-bEjUoa sc-boKDdR lhyyLL jYezsP"></span><span class="sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq">string</span><span class="sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq"> <!-- --><<!-- -->uuid<!-- -->><!-- --> </span></div> <div><div html="<p>Web page change monitor (watch) unique ID</p>
|
||||
" class="sc-eVqvcJ sc-fszimp kIppRw drqpJr"><p>Web page change monitor (watch) unique ID</p>
|
||||
@@ -1229,7 +1229,7 @@ Leave empty to use the colour auto-generated from the tag name.</p>
|
||||
" class="sc-eVqvcJ sc-fszimp kIppRw drqpJr"><p>Custom server</p>
|
||||
</div><div tabindex="0" role="button"><div class="sc-xKhEK okJpy"><span>{protocol}://{host}/api/v1</span>/tag/{uuid}</div></div></div></div></div><div><h3 class="sc-lgpSej drJHMo"> <!-- -->Request samples<!-- --> </h3><div class="sc-cOpnSz fyxuKi" data-rttabs="true"><ul class="react-tabs__tab-list" role="tablist"><li class="react-tabs__tab react-tabs__tab--selected" role="tab" id="tab_R_2abja_0" aria-selected="true" aria-disabled="false" aria-controls="panel_R_2abja_0" tabindex="0" data-rttab="true">curl</li><li class="react-tabs__tab" role="tab" id="tab_R_2abja_1" aria-selected="false" aria-disabled="false" aria-controls="panel_R_2abja_1" data-rttab="true">Python</li></ul><div class="react-tabs__tab-panel react-tabs__tab-panel--selected" role="tabpanel" id="panel_R_2abja_0" aria-labelledby="tab_R_2abja_0"><div class="sc-cdmAjP gsEOpk"><div class="sc-bbbBoY bBWkcI"><button><div class="sc-fYmhhH iNCOCX">Copy</div></button></div><pre class="sc-eVqvcJ sc-jytpVa kIppRw cCzeOT">curl <span class="token operator">-</span>X GET <span class="token string">"http://localhost:5000/api/v1/tag/550e8400-e29b-41d4-a716-446655440000"</span> \
|
||||
<span class="token operator">-</span>H <span class="token string">"x-api-key: YOUR_API_KEY"</span>
|
||||
</pre></div></div><div class="react-tabs__tab-panel" role="tabpanel" id="panel_R_2abja_1" aria-labelledby="tab_R_2abja_1"></div></div></div><div><h3 class="sc-lgpSej drJHMo"> <!-- -->Response samples<!-- --> </h3><div class="sc-cOpnSz fyxuKi" data-rttabs="true"><ul class="react-tabs__tab-list" role="tablist"><li class="tab-success react-tabs__tab--selected" role="tab" id="tab_R_2ebja_0" aria-selected="true" aria-disabled="false" aria-controls="panel_R_2ebja_0" tabindex="0" data-rttab="true">200</li></ul><div class="react-tabs__tab-panel react-tabs__tab-panel--selected" role="tabpanel" id="panel_R_2ebja_0" aria-labelledby="tab_R_2ebja_0"><div><div class="sc-bSFBcf iLdyBp"><span class="sc-gahYZc cXitJ">Content type</span><div class="sc-cCVJLD sc-gsJsQu dbfEBv ehbHlf"><svg class="sc-pYNGo eyTvTk" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg><select class="dropdown-select"><option value="application/json" selected="">application/json</option><option value="text/plain">text/plain</option></select><label>application/json</label></div></div><div class="sc-blIAwI eKKwxo"><div class="sc-dClGHI fdRrNy"><div class="sc-bbbBoY bBWkcI"><button><div class="sc-fYmhhH iNCOCX">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex="0" class="sc-eVqvcJ kIppRw sc-fhfEft dFvLDb"><div class="redoc-json"><code><button class="collapser" aria-label="collapse"></button><span class="token punctuation">{</span><span class="ellipsis"></span><ul class="obj collapsible"><li><div class="hoverable "><span class="property token string">"uuid"</span>: <span class="token string">"095be615-a8ad-4c33-8e9c-c7612fbf6c9f"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"date_created"</span>: <span class="token number">0</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"url"</span>: <span class="token string">"</span><a href="http://example.com">http://example.com</a><span class="token string">"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"title"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"tag"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"tags"</span>: <button class="collapser" aria-label="collapse"></button><span class="token punctuation">[</span><span class="ellipsis"></span><ul class="array collapsible"><li><div class="hoverable collapsed"><span class="token string">"string"</span></div></li></ul><span class="token punctuation">]</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"paused"</span>: <span class="token boolean">true</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"notification_muted"</span>: <span class="token boolean">true</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"method"</span>: <span class="token string">"GET"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"fetch_backend"</span>: <span class="token string">"system"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"headers"</span>: <button class="collapser" aria-label="collapse"></button><span class="token punctuation">{</span><span class="ellipsis"></span><ul class="obj collapsible"><li><div class="hoverable collapsed"><span class="property token string">"property1"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable collapsed"><span class="property token string">"property2"</span>: <span class="token string">"string"</span></div></li></ul><span class="token punctuation">}</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"body"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"proxy"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"ignore_status_codes"</span>Line truncated
|
||||
</pre></div></div><div class="react-tabs__tab-panel" role="tabpanel" id="panel_R_2abja_1" aria-labelledby="tab_R_2abja_1"></div></div></div><div><h3 class="sc-lgpSej drJHMo"> <!-- -->Response samples<!-- --> </h3><div class="sc-cOpnSz fyxuKi" data-rttabs="true"><ul class="react-tabs__tab-list" role="tablist"><li class="tab-success react-tabs__tab--selected" role="tab" id="tab_R_2ebja_0" aria-selected="true" aria-disabled="false" aria-controls="panel_R_2ebja_0" tabindex="0" data-rttab="true">200</li></ul><div class="react-tabs__tab-panel react-tabs__tab-panel--selected" role="tabpanel" id="panel_R_2ebja_0" aria-labelledby="tab_R_2ebja_0"><div><div class="sc-bSFBcf iLdyBp"><span class="sc-gahYZc cXitJ">Content type</span><div class="sc-cCVJLD sc-gsJsQu dbfEBv ehbHlf"><svg class="sc-pYNGo eyTvTk" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg><select class="dropdown-select"><option value="application/json" selected="">application/json</option><option value="text/plain">text/plain</option></select><label>application/json</label></div></div><div class="sc-blIAwI eKKwxo"><div class="sc-dClGHI fdRrNy"><div class="sc-bbbBoY bBWkcI"><button><div class="sc-fYmhhH iNCOCX">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex="0" class="sc-eVqvcJ kIppRw sc-fhfEft dFvLDb"><div class="redoc-json"><code><button class="collapser" aria-label="collapse"></button><span class="token punctuation">{</span><span class="ellipsis"></span><ul class="obj collapsible"><li><div class="hoverable "><span class="property token string">"uuid"</span>: <span class="token string">"095be615-a8ad-4c33-8e9c-c7612fbf6c9f"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"date_created"</span>: <span class="token number">0</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"url"</span>: <span class="token string">"</span><a href="http://example.com">http://example.com</a><span class="token string">"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"title"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"tag"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"tags"</span>: <button class="collapser" aria-label="collapse"></button><span class="token punctuation">[</span><span class="ellipsis"></span><ul class="array collapsible"><li><div class="hoverable collapsed"><span class="token string">"string"</span></div></li></ul><span class="token punctuation">]</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"paused"</span>: <span class="token boolean">true</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"notification_muted"</span>: <span class="token boolean">true</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"method"</span>: <span class="token string">"GET"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"fetch_backend"</span>: <span class="token string">"system"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"headers"</span>: <button class="collapser" aria-label="collapse"></button><span class="token punctuation">{</span><span class="ellipsis"></span><ul class="obj collapsible"><li><div class="hoverable collapsed"><span class="property token string">"property1"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable collapsed"><span class="property token string">"property2"</span>: <span class="token string">"string"</span></div></li></ul><span class="token punctuation">}</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"body"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"proxy"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"ignore_status_codes"</span>Line truncated
|
||||
" class="sc-eVqvcJ sc-fszimp kIppRw kbZred"><p>Update an existing tag using JSON</p>
|
||||
</div></div><div class="sc-ikkVnJ deUlC"><div class="sc-hWgKua dPSGXF"><h5 class="sc-eqYatC sc-gFqXPY czjApA jCoZLr">Authorizations:</h5><svg class="sc-dntSTA FtowP" version="1.1" viewBox="0 0 24 24" x="0" xmlns="http://www.w3.org/2000/svg" y="0" aria-hidden="true"><polygon points="17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 "></polygon></svg></div><div class="sc-jBaHRL fUkQtw"><span class="sc-iVnIWt gRXavu"><span class="sc-hqtLyI hRtRoN"><i>ApiKeyAuth</i></span></span></div></div><div><h5 class="sc-eqYatC czjApA">path<!-- --> Parameters</h5><table class="sc-eqNDNG icJLQx"><tbody><tr class="last "><td kind="field" title="uuid" class="sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ"><span class="sc-hwddKA cteAyA"></span><span class="property-name">uuid</span><div class="sc-bEjUoa sc-iIvHqT lhyyLL crXmiY">required</div></td><td class="sc-gGKoUb ixGaBD"><div><div><span class="sc-bEjUoa sc-boKDdR lhyyLL jYezsP"></span><span class="sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq">string</span><span class="sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq"> <!-- --><<!-- -->uuid<!-- -->><!-- --> </span></div> <div><div html="<p>Tag unique ID</p>
|
||||
" class="sc-eVqvcJ sc-fszimp kIppRw drqpJr"><p>Tag unique ID</p>
|
||||
@@ -1385,7 +1385,7 @@ Leave empty to use the colour auto-generated from the tag name.</p>
|
||||
" class="sc-eVqvcJ sc-fszimp kIppRw drqpJr"><p>Production server</p>
|
||||
</div><div tabindex="0" role="button"><div class="sc-xKhEK okJpy"><span>https://yourdomain.com/api/v1</span>/tag/{uuid}</div></div></div><div class="sc-iyBeIh icOxsG"><div html="<p>Custom server</p>
|
||||
" class="sc-eVqvcJ sc-fszimp kIppRw drqpJr"><p>Custom server</p>
|
||||
</div><div tabindex="0" role="button"><div class="sc-xKhEK okJpy"><span>{protocol}://{host}/api/v1</span>/tag/{uuid}</div></div></div></div></div><div><h3 class="sc-lgpSej drJHMo"> <!-- -->Request samples<!-- --> </h3><div class="sc-cOpnSz fyxuKi" data-rttabs="true"><ul class="react-tabs__tab-list" role="tablist"><li class="react-tabs__tab react-tabs__tab--selected" role="tab" id="tab_R_2acja_0" aria-selected="true" aria-disabled="false" aria-controls="panel_R_2acja_0" tabindex="0" data-rttab="true">Payload</li><li class="react-tabs__tab" role="tab" id="tab_R_2acja_1" aria-selected="false" aria-disabled="false" aria-controls="panel_R_2acja_1" data-rttab="true">curl</li><li class="react-tabs__tab" role="tab" id="tab_R_2acja_2" aria-selected="false" aria-disabled="false" aria-controls="panel_R_2acja_2" data-rttab="true">Python</li></ul><div class="react-tabs__tab-panel react-tabs__tab-panel--selected" role="tabpanel" id="panel_R_2acja_0" aria-labelledby="tab_R_2acja_0"><div><div class="sc-bSFBcf iLdyBp"><span class="sc-gahYZc cXitJ">Content type</span><div class="sc-bAehkN iNRAJK">application/json</div></div><div class="sc-blIAwI eKKwxo"><div class="sc-dClGHI fdRrNy"><div class="sc-bbbBoY bBWkcI"><button><div class="sc-fYmhhH iNCOCX">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex="0" class="sc-eVqvcJ kIppRw sc-fhfEft dFvLDb"><div class="redoc-json"><code><button class="collapser" aria-label="collapse"></button><span class="token punctuation">{</span><span class="ellipsis"></span><ul class="obj collapsible"><li><div class="hoverable "><span class="property token string">"url"</span>: <span class="token string">"</span><a href="http://example.com">http://example.com</a><span class="token string">"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"title"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"tag"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"tags"</span>: <button class="collapser" aria-label="collapse"></button><span class="token punctuation">[</span><span class="ellipsis"></span><ul class="array collapsible"><li><div class="hoverable collapsed"><span class="token string">"string"</span></div></li></ul><span class="token punctuation">]</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"paused"</span>: <span class="token boolean">true</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"notification_muted"</span>: <span class="token boolean">true</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"method"</span>: <span class="token string">"GET"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"fetch_backend"</span>: <span class="token string">"system"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"headers"</span>: <button class="collapser" aria-label="collapse"></button><span class="token punctuation">{</span><span class="ellipsis"></span><ul class="obj collapsible"><li><div class="hoverable collapsed"><span class="property token string">"property1"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable collapsed"><span class="property token string">"property2"</span>: <span class="token string">"string"</span></div></li></ul><span class="token punctuation">}</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"body"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"proxy"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"ignore_status_codes"</span>: <span class="token boolean">true</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"webdriver_delay"</span>: <span class="token number">0</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"webdriver_js_execute_code"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoveraLine truncated
|
||||
</div><div tabindex="0" role="button"><div class="sc-xKhEK okJpy"><span>{protocol}://{host}/api/v1</span>/tag/{uuid}</div></div></div></div></div><div><h3 class="sc-lgpSej drJHMo"> <!-- -->Request samples<!-- --> </h3><div class="sc-cOpnSz fyxuKi" data-rttabs="true"><ul class="react-tabs__tab-list" role="tablist"><li class="react-tabs__tab react-tabs__tab--selected" role="tab" id="tab_R_2acja_0" aria-selected="true" aria-disabled="false" aria-controls="panel_R_2acja_0" tabindex="0" data-rttab="true">Payload</li><li class="react-tabs__tab" role="tab" id="tab_R_2acja_1" aria-selected="false" aria-disabled="false" aria-controls="panel_R_2acja_1" data-rttab="true">curl</li><li class="react-tabs__tab" role="tab" id="tab_R_2acja_2" aria-selected="false" aria-disabled="false" aria-controls="panel_R_2acja_2" data-rttab="true">Python</li></ul><div class="react-tabs__tab-panel react-tabs__tab-panel--selected" role="tabpanel" id="panel_R_2acja_0" aria-labelledby="tab_R_2acja_0"><div><div class="sc-bSFBcf iLdyBp"><span class="sc-gahYZc cXitJ">Content type</span><div class="sc-bAehkN iNRAJK">application/json</div></div><div class="sc-blIAwI eKKwxo"><div class="sc-dClGHI fdRrNy"><div class="sc-bbbBoY bBWkcI"><button><div class="sc-fYmhhH iNCOCX">Copy</div></button><button> Expand all </button><button> Collapse all </button></div><div tabindex="0" class="sc-eVqvcJ kIppRw sc-fhfEft dFvLDb"><div class="redoc-json"><code><button class="collapser" aria-label="collapse"></button><span class="token punctuation">{</span><span class="ellipsis"></span><ul class="obj collapsible"><li><div class="hoverable "><span class="property token string">"url"</span>: <span class="token string">"</span><a href="http://example.com">http://example.com</a><span class="token string">"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"title"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"tag"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"tags"</span>: <button class="collapser" aria-label="collapse"></button><span class="token punctuation">[</span><span class="ellipsis"></span><ul class="array collapsible"><li><div class="hoverable collapsed"><span class="token string">"string"</span></div></li></ul><span class="token punctuation">]</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"paused"</span>: <span class="token boolean">true</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"notification_muted"</span>: <span class="token boolean">true</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"method"</span>: <span class="token string">"GET"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"fetch_backend"</span>: <span class="token string">"system"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"headers"</span>: <button class="collapser" aria-label="collapse"></button><span class="token punctuation">{</span><span class="ellipsis"></span><ul class="obj collapsible"><li><div class="hoverable collapsed"><span class="property token string">"property1"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable collapsed"><span class="property token string">"property2"</span>: <span class="token string">"string"</span></div></li></ul><span class="token punctuation">}</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"body"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"proxy"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"ignore_status_codes"</span>: <span class="token boolean">true</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"webdriver_delay"</span>: <span class="token number">0</span><span class="token punctuation">,</span></div></li><li><div class="hoverable "><span class="property token string">"webdriver_js_execute_code"</span>: <span class="token string">"string"</span><span class="token punctuation">,</span></div></li><li><div class="hoveraLine truncated
|
||||
" class="sc-eVqvcJ sc-fszimp kIppRw kbZred"><p>Delete a tag/group and remove it from all web page change monitors (watches)</p>
|
||||
</div></div><div class="sc-ikkVnJ deUlC"><div class="sc-hWgKua dPSGXF"><h5 class="sc-eqYatC sc-gFqXPY czjApA jCoZLr">Authorizations:</h5><svg class="sc-dntSTA FtowP" version="1.1" viewBox="0 0 24 24" x="0" xmlns="http://www.w3.org/2000/svg" y="0" aria-hidden="true"><polygon points="17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 "></polygon></svg></div><div class="sc-jBaHRL fUkQtw"><span class="sc-iVnIWt gRXavu"><span class="sc-hqtLyI hRtRoN"><i>ApiKeyAuth</i></span></span></div></div><div><h5 class="sc-eqYatC czjApA">path<!-- --> Parameters</h5><table class="sc-eqNDNG icJLQx"><tbody><tr class="last "><td kind="field" title="uuid" class="sc-kCuUfV sc-fbQrwq gdmNWp dFOJWJ"><span class="sc-hwddKA cteAyA"></span><span class="property-name">uuid</span><div class="sc-bEjUoa sc-iIvHqT lhyyLL crXmiY">required</div></td><td class="sc-gGKoUb ixGaBD"><div><div><span class="sc-bEjUoa sc-boKDdR lhyyLL jYezsP"></span><span class="sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq">string</span><span class="sc-bEjUoa sc-fOOuSg lhyyLL dbKJYq"> <!-- --><<!-- -->uuid<!-- -->><!-- --> </span></div> <div><div html="<p>Tag unique ID</p>
|
||||
" class="sc-eVqvcJ sc-fszimp kIppRw drqpJr"><p>Tag unique ID</p>
|
||||
@@ -1884,7 +1884,7 @@ installed processors (e.g. <code>processor_config_restock_diff</code> from the b
|
||||
curl <span class="token operator">-</span>X GET <span class="token string">"http://localhost:5000/api/v1/full-spec"</span>
|
||||
</pre></div></div><div class="react-tabs__tab-panel" role="tabpanel" id="panel_R_156lq_1" aria-labelledby="tab_R_156lq_1"></div></div></div></div></div></div></div><div class="sc-evkzZa iZqpqg"></div></div></div>
|
||||
<script>
|
||||
const __redoc_state = {"menu":{"activeItemIdx":-1},"spec":{"data":{"openapi":"3.1.0","info":{"title":"ChangeDetection.io API","description":"# ChangeDetection.io Web page monitoring and notifications API\n\nREST API for managing Page watches, Group tags, and Notifications.\n\nchangedetection.io can be driven by its built in simple API, in the examples below you will also find `curl` command line and `python` examples to help you get started faster.\n\n## Where to find my API key?\n\nThe API key can be easily found under the **SETTINGS** then **API** tab of changedetection.io dashboard. \nSimply click the API key to automatically copy it to your clipboard.\n\n\n\n## Connection URL\n\nThe API can be found at `/api/v1/`, so for example if you run changedetection.io locally on port 5000, then URL would be `http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history`.\n\nIf you are using the hosted/subscription version of changedetection.io, then the URL is based on your login URL, for example: \n`https://<your login url>/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history`\n\n## Authentication\n\nAlmost all API requests require some authentication, this is provided as an **API Key** in the header of the HTTP request.\n\nFor example: `x-api-key: YOUR_API_KEY`\n","version":"0.1.7","contact":{"name":"ChangeDetection.io","url":"https://github.com/dgtlmoon/changedetection.io"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"}},"servers":[{"url":"http://localhost:5000/api/v1","description":"Development server"},{"url":"https://yourdomain.com/api/v1","description":"Production server"},{"url":"{protocol}://{host}/api/v1","description":"Custom server","variables":{"protocol":{"enum":["http","https"],"default":"https"},"host":{"default":"yourdomain.com","description":"Your changedetection.io host"}}}],"security":[{"ApiKeyAuth":[]}],"tags":[{"name":"Watch Management","description":"Core functionality for managing web page monitors. Create, retrieve, update, and delete individual watches. \nEach watch represents a single URL being monitored for changes, with configurable settings for check intervals, \nnotification preferences, and content filtering options.\n"},{"name":"Watch History","description":"Get a list of timestamps of all changes detected for a watch.\n"},{"name":"Snapshots","description":"Retrieve individual text snapshot of monitored content according to the `timestamp`. The text snapshot is the HTML\nto Text at page check time. \n\nSet the query argument `html` to any value to retrieve the last HTML fetched, the system only keeps the last two \n(2) HTML files fetched.\n\nUse the Watch History API endpoint to get a list of timestamps to pass to this query.\n"},{"name":"Favicon","description":"Retrieve favicon images associated with monitored web pages. These are used in the dashboard interface \nto visually identify different watches in your monitoring list.\n"},{"name":"Group / Tag Management","description":"Organize your watches using tags and groups. Tags (also known as Groups) allow you to categorize monitors, set group-wide \nnotification preferences, and perform bulk operations like mass rechecking or status changes across \nmultiple related watches.\n"},{"name":"Notifications","description":"Configure global notification endpoints that can be used across all your watches. Supports various \nnotification services including email, Discord, Slack, webhooks, and many other popular platforms. \nThese settings serve as defaults that can be overridden at the individual watch or tag level.\n\nThe notification syntax uses [https://github.com/caronc/apprise](https://github.com/caronc/apprise).\n"},{"name":"Search","description":"Search and filter your watches by URL patterns, titles, or tags. Useful for quickly finding specific \nmonitors in large collections or identifying watches that match certain criteria.\n"},{"name":"Import","description":"Bulk import multiple URLs for monitoring. Accepts plain text lists of URLs and can automatically \napply tags, proxy settings, and other configurations to all imported watches simultaneously.\n"},{"name":"System Information","description":"Retrieve system status and statistics about your changedetection.io instance, including total watch\ncounts, uptime information, and version details.\n"},{"name":"Plugin API Extensions","description":"## How Processor Plugins Extend the API\n\nchangedetection.io uses a **processor plugin** system to handle different types of change detection.\nEach processor lives in `changedetectionio/processors/<name>/` and may include an `api.yaml` file\nthat extends the core Watch schema with processor-specific configuration fields.\n\n### How it works\n\nAt startup, changedetection.io scans all installed processors for an `api.yaml` file. Any schemas\nand code samples defined there are deep-merged into the live API speciLine truncated
|
||||
const __redoc_state = {"menu":{"activeItemIdx":-1},"spec":{"data":{"openapi":"3.1.0","info":{"title":"ChangeDetection.io API","description":"# ChangeDetection.io Web page monitoring and notifications API\n\nREST API for managing Page watches, Group tags, and Notifications.\n\nchangedetection.io can be driven by its built in simple API, in the examples below you will also find `curl` command line and `python` examples to help you get started faster.\n\n## Where to find my API key?\n\nThe API key can be easily found under the **SETTINGS** then **API** tab of changedetection.io dashboard. \nSimply click the API key to automatically copy it to your clipboard.\n\n\n\n## Connection URL\n\nThe API can be found at `/api/v1/`, so for example if you run changedetection.io locally on port 5000, then URL would be `http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history`.\n\nIf you are using the hosted/subscription version of changedetection.io, then the URL is based on your login URL, for example: \n`https://<your login url>/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history`\n\n## Authentication\n\nAlmost all API requests require some authentication, this is provided as an **API Key** in the header of the HTTP request.\n\nFor example: `x-api-key: YOUR_API_KEY`\n","version":"0.1.7","contact":{"name":"ChangeDetection.io","url":"https://github.com/dgtlmoon/changedetection.io"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"}},"servers":[{"url":"http://localhost:5000/api/v1","description":"Development server"},{"url":"https://yourdomain.com/api/v1","description":"Production server"},{"url":"{protocol}://{host}/api/v1","description":"Custom server","variables":{"protocol":{"enum":["http","https"],"default":"https"},"host":{"default":"yourdomain.com","description":"Your changedetection.io host"}}}],"security":[{"ApiKeyAuth":[]}],"tags":[{"name":"Watch Management","description":"Core functionality for managing web page monitors. Create, retrieve, update, and delete individual watches. \nEach watch represents a single URL being monitored for changes, with configurable settings for check intervals, \nnotification preferences, and content filtering options.\n"},{"name":"Watch History","description":"Get a list of timestamps of all changes detected for a watch.\n"},{"name":"Snapshots","description":"Retrieve individual text snapshot of monitored content according to the `timestamp`. The text snapshot is the HTML\nto Text at page check time. \n\nSet the query argument `html` to any value to retrieve the last HTML fetched, the system only keeps the last two \n(2) HTML files fetched.\n\nUse the Watch History API endpoint to get a list of timestamps to pass to this query.\n"},{"name":"Favicon","description":"Retrieve favicon images associated with monitored web pages. These are used in the dashboard interface \nto visually identify different watches in your monitoring list.\n"},{"name":"Group / Tag Management","description":"Organize your watches using tags and groups. Tags (also known as Groups) allow you to categorize monitors, set group-wide \nnotification preferences, and perform bulk operations like mass rechecking or status changes across \nmultiple related watches.\n"},{"name":"Notifications","description":"Configure global notification endpoints that can be used across all your watches. Supports various \nnotification services including email, Discord, Slack, webhooks, and many other popular platforms. \nThese settings serve as defaults that can be overridden at the individual watch or tag level.\n\nThe notification syntax uses [https://github.com/caronc/apprise](https://github.com/caronc/apprise).\n"},{"name":"Search","description":"Search and filter your watches by URL patterns, titles, or tags. Useful for quickly finding specific \nmonitors in large collections or identifying watches that match certain criteria.\n"},{"name":"Import","description":"Bulk import multiple URLs for monitoring. Accepts plain text lists of URLs and can automatically \napply tags, proxy settings, and other configurations to all imported watches simultaneously.\n"},{"name":"System Information","description":"Retrieve system status and statistics about your changedetection.io instance, including total watch\ncounts, uptime information, and version details.\n"},{"name":"Plugin API Extensions","description":"## How Processor Plugins Extend the API\n\nchangedetection.io uses a **processor plugin** system to handle different types of change detection.\nEach processor lives in `changedetectionio/processors/<name>/` and may include an `api.yaml` file\nthat extends the core Watch schema with processor-specific configuration fields.\n\n### How it works\n\nAt startup, changedetection.io scans all installed processors for an `api.yaml` file. Any schemas\nand code samples defined there are deep-merged into the live API speciLine truncated
|
||||
|
||||
var container = document.getElementById('redoc');
|
||||
Redoc.hydrate(__redoc_state, container);
|
||||
|
||||
Reference in new issue
Block a user