diff --git a/api/monitor/models.py b/api/monitor/models.py index f3e439d..b4a4ef5 100644 --- a/api/monitor/models.py +++ b/api/monitor/models.py @@ -75,7 +75,7 @@ MODE_NOTE = "note" class MonitorTask(MonitorBase): - """One monitored schedule: a set of targets plus an interval.""" + """One monitored schedule: a set of targets, plus when to run them.""" __tablename__ = "monitor_task" @@ -86,6 +86,21 @@ class MonitorTask(MonitorBase): enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=360) + # How the task is scheduled. `interval` is the original "every N minutes" and + # stays the default; `daily` and `weekly` fire at chosen clock times instead + # (the arithmetic lives in schedule.py). + # + # The clock fields are comma-separated text rather than a child table: they + # are a handful of small integers, always read as a whole, and a table would + # buy nothing but joins. + schedule_mode: Mapped[str] = mapped_column(String(16), nullable=False, default="interval") + # 0-23, e.g. "9,12,18". Empty in interval mode. + schedule_hours: Mapped[str] = mapped_column(String(96), nullable=False, default="") + # 0-6 with Monday = 0, matching Python's date.weekday(). Weekly mode only. + schedule_days: Mapped[str] = mapped_column(String(32), nullable=False, default="") + # Minute past the hour, shared by every time in the schedule. + schedule_minute: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # Crawl window knobs, mirrored onto each run's CLI flags. max_notes_count: Mapped[int] = mapped_column(Integer, nullable=False, default=20) enable_comments: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) diff --git a/api/monitor/schedule.py b/api/monitor/schedule.py new file mode 100644 index 0000000..b9ba7ae --- /dev/null +++ b/api/monitor/schedule.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/monitor/schedule.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +"""Monitor task schedule arithmetic. + +Three modes, all expressible by a picker. A raw cron string was ruled out on +purpose -- it is a small language, and the operator should not have to write one +to say "every day at nine": + +* ``interval`` -- every N minutes. +* ``daily`` -- at chosen clock times, e.g. 09:00 and 18:30. +* ``weekly`` -- at chosen clock times on chosen weekdays, e.g. Mon-Fri 10:00. + +The two clock modes are **fixed-time**, unlike ``interval``, which is fixed-delay. +The distinction matters: for an interval, measuring the next slot from when the +run starts is what stops a slow run from firing back-to-back. For a clock +schedule it would be wrong, because a run that starts at 09:07 would drag every +later run seven minutes late, compounding all day. + +Fixed-time also means **no jitter is applied** to clock schedules. The operator +picked a time; quietly running at 09:04 instead of 09:00 is not a feature, it just +looks like a bug. ``interval`` keeps its jitter, where there is no stated time to +contradict. + +All arithmetic is in the server's local timezone -- naive datetimes on purpose, +because the container is pinned to the operator's zone via TZ and pretending +otherwise would add a timezone concept nobody asked for. +""" + +from datetime import datetime, time, timedelta +from typing import Optional, Sequence + +MODE_INTERVAL = "interval" +MODE_DAILY = "daily" +MODE_WEEKLY = "weekly" + +SCHEDULE_MODES = (MODE_INTERVAL, MODE_DAILY, MODE_WEEKLY) +CLOCK_MODES = (MODE_DAILY, MODE_WEEKLY) + +_MS_PER_MINUTE = 60_000 +_WEEKDAY_NAMES = "一二三四五六日" + + +def parse_hours(raw: Optional[str]) -> list[int]: + """``"9,18"`` -> ``[9, 18]``. Sorted, de-duplicated, junk dropped.""" + return _parse_int_list(raw, 0, 23) + + +def parse_days(raw: Optional[str]) -> list[int]: + """``"0,2,4"`` -> ``[0, 2, 4]``. **0 is Monday**, matching ``date.weekday()``.""" + return _parse_int_list(raw, 0, 6) + + +def _parse_int_list(raw: Optional[str], low: int, high: int) -> list[int]: + values: set[int] = set() + for chunk in (raw or "").split(","): + chunk = chunk.strip() + if not chunk: + continue + try: + number = int(chunk) + except ValueError: + # Stored values come from our own UI, but a hand-edited row must not + # be able to crash the scheduler loop. + continue + if low <= number <= high: + values.add(number) + return sorted(values) + + +def format_hours(hours: Sequence[int]) -> str: + return ",".join(str(hour) for hour in sorted(set(hours))) + + +def format_days(days: Sequence[int]) -> str: + return ",".join(str(day) for day in sorted(set(days))) + + +def describe( + *, + mode: str, + interval_minutes: int, + hours: Sequence[int], + days: Sequence[int], + minute: int, +) -> str: + """One human sentence for the task card. + + Lives here rather than in the frontend so the list view and the editor cannot + drift apart on what a schedule means. + """ + if mode == MODE_INTERVAL: + if interval_minutes % 1440 == 0: + return f"每 {interval_minutes // 1440} 天" + if interval_minutes % 60 == 0: + return f"每 {interval_minutes // 60} 小时" + return f"每 {interval_minutes} 分钟" + + if not hours: + return "未设置时间" + + clock = "、".join(f"{hour:02d}:{minute:02d}" for hour in sorted(set(hours))) + + if mode == MODE_DAILY: + return f"每天 {clock}" + + if not days: + return f"每天 {clock}" + labels = "、".join(f"周{_WEEKDAY_NAMES[day]}" for day in sorted(set(days))) + return f"{labels} {clock}" + + +def next_occurrence( + *, + mode: str, + interval_minutes: int, + hours: Sequence[int], + days: Sequence[int], + minute: int, + after_ms: int, +) -> Optional[int]: + """The next fire time strictly after ``after_ms``, as epoch milliseconds. + + ``None`` means the schedule can never fire -- a clock mode with no hours + chosen. Callers store that as "no next run" rather than something in the past, + which would otherwise leave the task permanently due and re-running on every + tick. + """ + if mode == MODE_INTERVAL: + return after_ms + max(1, interval_minutes) * _MS_PER_MINUTE + + if not hours: + return None + + now = datetime.fromtimestamp(after_ms / 1000) + # No weekdays chosen means every day, matching describe(). Without the `days` + # guard an empty selection would produce an empty allowed set, no matching day, + # and a task that silently never runs. + allowed_days = set(days) if (mode == MODE_WEEKLY and days) else set(range(7)) + + # Eight days of lookahead covers today's remaining slots plus a full week, + # which is more than any weekday selection can need. + for offset in range(8): + day = (now + timedelta(days=offset)).date() + if day.weekday() not in allowed_days: + continue + for hour in sorted(set(hours)): + candidate = datetime.combine(day, time(hour=hour, minute=minute)) + if candidate > now: + return int(candidate.timestamp() * 1000) + + return None diff --git a/api/monitor/scheduler.py b/api/monitor/scheduler.py index 0792f1a..cf6c63b 100644 --- a/api/monitor/scheduler.py +++ b/api/monitor/scheduler.py @@ -23,8 +23,15 @@ is enough here: there is exactly one process, one global crawler subprocess, and therefore no concurrency to coordinate -- a cron-style library would add a dependency without adding a capability. -Scheduling is **fixed-delay**, not fixed-rate: ``next_run_at`` is set from the -moment a run starts, so a slow run cannot make its task fire back-to-back. +Two families of schedule, and the difference matters: + +* ``interval`` is **fixed-delay**, not fixed-rate -- ``next_run_at`` is measured + from the moment a run starts, so a slow run cannot make its task fire + back-to-back. +* the clock modes (``daily``/``weekly``) are **fixed-time** -- recomputed from the + calendar, so a run that starts late does not drag every later run with it. + +The arithmetic for both lives in schedule.py. """ import asyncio @@ -37,7 +44,7 @@ from sqlalchemy import select from tools.time_util import get_current_timestamp from ..services import crawler_manager -from . import app_settings +from . import app_settings, schedule from .db import get_session from .models import MonitorRun, MonitorTask, RUN_INTERRUPTED, RUN_RUNNING from .runner import execute_task @@ -45,10 +52,9 @@ from .settings import get_cookie POLL_INTERVAL_SECONDS = 20 # Spread tasks sharing an interval so they do not all come due on the same tick. +# Applied to interval mode only -- see the advance step below. JITTER_SECONDS = 60 -_MS_PER_MINUTE = 60_000 - class MonitorScheduler: """Polls the task table and runs whatever is due.""" @@ -168,11 +174,34 @@ class MonitorScheduler: # Advance before running so a crash mid-run cannot cause an immediate # re-fire, and so a long outage coalesces into a single run instead # of one run per missed interval. - task.next_run_at = ( - get_current_timestamp() - + task.interval_minutes * _MS_PER_MINUTE - + random.randint(0, JITTER_SECONDS) * 1000 + now = get_current_timestamp() + following = schedule.next_occurrence( + mode=task.schedule_mode, + interval_minutes=task.interval_minutes, + hours=schedule.parse_hours(task.schedule_hours), + days=schedule.parse_days(task.schedule_days), + minute=task.schedule_minute, + after_ms=now, ) + + if following is None: + # A clock schedule with no times can never fire. The API rejects + # that shape, so this guards against a hand-edited row: park the + # task with no next run rather than leaving it permanently due and + # re-running it on every tick. + task.next_run_at = None + print( + f"[monitor.scheduler] task {task.id} has no usable schedule " + f"and will not run until one is set" + ) + elif task.schedule_mode == schedule.MODE_INTERVAL: + # Jitter belongs to the interval mode only. Spreading identical + # intervals apart is the point; nudging a time the operator + # explicitly picked is not -- it just looks like a broken clock. + task.next_run_at = following + random.randint(0, JITTER_SECONDS) * 1000 + else: + task.next_run_at = following + task_id = task.id try: diff --git a/api/monitor/service.py b/api/monitor/service.py index 20c03bd..117e81d 100644 --- a/api/monitor/service.py +++ b/api/monitor/service.py @@ -28,7 +28,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from tools.time_util import get_current_timestamp -from . import app_settings, platforms +from . import app_settings, platforms, schedule from .db import get_session from .platforms import PLATFORM_XHS from .models import ( @@ -53,6 +53,28 @@ _background_runs: set[asyncio.Task] = set() MIN_INTERVAL_MINUTES = 30 MAX_INTERVAL_MINUTES = 7 * 24 * 60 +# Changing any of these invalidates the pending run slot. +SCHEDULE_FIELDS = { + "interval_minutes", + "schedule_mode", + "schedule_hours", + "schedule_days", + "schedule_minute", +} + + +def _require_clock_fields(mode: str, hours: list, days: list) -> None: + """A clock schedule with no clock time can never fire. + + The create schema already rejects that shape, but the update path merges + partial fields and therefore has no schema-level view of the result -- so the + check lives here, where both paths meet. + """ + if mode in schedule.CLOCK_MODES and not hours: + raise ValueError("按钟点调度至少要选一个时间") + if mode == schedule.MODE_WEEKLY and not days: + raise ValueError("按周调度至少要选一个星期") + _CREATOR_URL_RE = re.compile(r"xiaohongshu\.com/user/profile/([A-Za-z0-9_-]+)") _NOTE_URL_RE = re.compile(r"xiaohongshu\.com/(?:explore|discovery/item)/([A-Za-z0-9_-]+)") # XHS user ids and note ids are 24-char hex; allow a slightly wider range so a @@ -139,7 +161,12 @@ async def create_task(session: AsyncSession, payload: Dict[str, Any]) -> Monitor # the Settings page actually governs new tasks. defaults = await app_settings.defaults(session, platform) interval_minutes = payload.get("interval_minutes") or defaults["interval_minutes"] - interval_ms = int(interval_minutes) * 60_000 + + schedule_mode = payload.get("schedule_mode") or schedule.MODE_INTERVAL + schedule_hours = list(payload.get("schedule_hours") or []) + schedule_days = list(payload.get("schedule_days") or []) + schedule_minute = int(payload.get("schedule_minute") or 0) + _require_clock_fields(schedule_mode, schedule_hours, schedule_days) task = MonitorTask( name=payload["name"], @@ -147,12 +174,23 @@ async def create_task(session: AsyncSession, payload: Dict[str, Any]) -> Monitor mode=mode, enabled=payload.get("enabled", True), interval_minutes=interval_minutes, + schedule_mode=schedule_mode, + schedule_hours=schedule.format_hours(schedule_hours), + schedule_days=schedule.format_days(schedule_days), + schedule_minute=schedule_minute, max_notes_count=payload.get("max_notes_count") or defaults["max_notes_count"], enable_comments=payload.get("enable_comments", True), max_comments_count=payload.get("max_comments_count") or defaults["max_comments_count"], run_timeout_seconds=payload.get("run_timeout_seconds", 3600), notify_enabled=payload.get("notify_enabled", False), - next_run_at=now + interval_ms, + next_run_at=schedule.next_occurrence( + mode=schedule_mode, + interval_minutes=interval_minutes, + hours=schedule_hours, + days=schedule_days, + minute=schedule_minute, + after_ms=now, + ), last_status="idle", created_at=now, updated_at=now, @@ -189,10 +227,14 @@ async def update_task(session: AsyncSession, task_id: int, payload: Dict[str, An if task is None: raise ValueError(f"Task {task_id} not found") + was_enabled = task.enabled + for field in ( "name", "enabled", "interval_minutes", + "schedule_mode", + "schedule_minute", "max_notes_count", "enable_comments", "max_comments_count", @@ -202,6 +244,13 @@ async def update_task(session: AsyncSession, task_id: int, payload: Dict[str, An if field in payload and payload[field] is not None: setattr(task, field, payload[field]) + # The clock lists are stored as comma-separated text, so they cannot go + # through the generic loop above. + if payload.get("schedule_hours") is not None: + task.schedule_hours = schedule.format_hours(payload["schedule_hours"]) + if payload.get("schedule_days") is not None: + task.schedule_days = schedule.format_days(payload["schedule_days"]) + # Replacing targets resets the baseline implicitly: a note set that now # includes new ids will simply report them as new on the next run. if payload.get("targets") is not None: @@ -227,8 +276,23 @@ async def update_task(session: AsyncSession, task_id: int, payload: Dict[str, An ) ) - if "interval_minutes" in payload and payload["interval_minutes"]: - task.next_run_at = get_current_timestamp() + payload["interval_minutes"] * 60_000 + # Any change to when the task runs invalidates the pending slot, so recompute + # it from the merged state rather than working out which field moved. + # Re-enabling counts as a change too: otherwise a task switched off for a + # month comes back holding a next_run_at a month in the past and fires the + # instant it is saved. + if (SCHEDULE_FIELDS & set(payload)) or (task.enabled and not was_enabled): + hours = schedule.parse_hours(task.schedule_hours) + days = schedule.parse_days(task.schedule_days) + _require_clock_fields(task.schedule_mode, hours, days) + task.next_run_at = schedule.next_occurrence( + mode=task.schedule_mode, + interval_minutes=task.interval_minutes, + hours=hours, + days=days, + minute=task.schedule_minute, + after_ms=get_current_timestamp(), + ) task.updated_at = get_current_timestamp() await session.flush() @@ -623,6 +687,7 @@ async def list_tasks( "mode": task.mode, "enabled": task.enabled, "interval_minutes": task.interval_minutes, + **_schedule_fields(task), "max_notes_count": task.max_notes_count, "enable_comments": task.enable_comments, "max_comments_count": task.max_comments_count, @@ -644,6 +709,29 @@ async def list_tasks( ] +def _schedule_fields(task: MonitorTask) -> Dict[str, Any]: + """The schedule columns, plus the sentence the task list renders. + + The label is composed here rather than in the frontend so the list and the + editor cannot drift on what a given schedule means. + """ + hours = schedule.parse_hours(task.schedule_hours) + days = schedule.parse_days(task.schedule_days) + return { + "schedule_mode": task.schedule_mode, + "schedule_hours": hours, + "schedule_days": days, + "schedule_minute": task.schedule_minute, + "schedule_label": schedule.describe( + mode=task.schedule_mode, + interval_minutes=task.interval_minutes, + hours=hours, + days=days, + minute=task.schedule_minute, + ), + } + + async def overview(session: AsyncSession, platform: Optional[str] = None) -> Dict[str, Any]: """Headline numbers for the dashboard tiles, scoped to one platform.""" now = get_current_timestamp() diff --git a/api/schemas/monitor.py b/api/schemas/monitor.py index f396503..f74c7a1 100644 --- a/api/schemas/monitor.py +++ b/api/schemas/monitor.py @@ -20,7 +20,7 @@ from typing import List, Literal, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator # A floor on the interval is a correctness guard, not a nicety: every run # launches a browser and hits XHS with several requests, so a short interval @@ -40,6 +40,19 @@ class MonitorTaskCreate(BaseModel): interval_minutes: Optional[int] = Field( default=None, ge=MIN_INTERVAL_MINUTES, le=MAX_INTERVAL_MINUTES ) + # --- Scheduling --------------------------------------------------------- + # All three modes are expressible with pickers; a raw cron string is + # deliberately not supported, since it is a small language to learn just to + # say "every day at nine". + schedule_mode: Literal["interval", "daily", "weekly"] = "interval" + # 0-23, e.g. [9, 12, 18]. Required for the two clock modes. + schedule_hours: List[int] = Field(default_factory=list) + # 0-6 with Monday = 0, matching Python's date.weekday(). Required for weekly. + schedule_days: List[int] = Field(default_factory=list) + # One minute for the whole schedule, so a task with three times is + # "09:30, 12:30, 18:30" rather than three separate minute choices. + schedule_minute: int = Field(default=0, ge=0, le=59) + max_notes_count: Optional[int] = Field(default=None, ge=1, le=500) enable_comments: bool = True # Raising this widens the comment window, which is the only lever available @@ -53,6 +66,20 @@ class MonitorTaskCreate(BaseModel): # Raw pasted values: full URLs or bare ids, in either form. targets: List[str] = Field(min_length=1) + @model_validator(mode="after") + def _validate_schedule(self) -> "MonitorTaskCreate": + if any(hour < 0 or hour > 23 for hour in self.schedule_hours): + raise ValueError("小时必须在 0-23 之间") + if any(day < 0 or day > 6 for day in self.schedule_days): + raise ValueError("星期必须在 0-6 之间(周一为 0)") + # A clock mode with no chosen time can never fire. Rejecting it here is + # what keeps next_occurrence()'s None branch unreachable in practice. + if self.schedule_mode in ("daily", "weekly") and not self.schedule_hours: + raise ValueError("按钟点调度至少要选一个时间") + if self.schedule_mode == "weekly" and not self.schedule_days: + raise ValueError("按周调度至少要选一个星期") + return self + class MonitorTaskUpdate(BaseModel): name: Optional[str] = Field(default=None, min_length=1, max_length=200) @@ -60,6 +87,12 @@ class MonitorTaskUpdate(BaseModel): interval_minutes: Optional[int] = Field( default=None, ge=MIN_INTERVAL_MINUTES, le=MAX_INTERVAL_MINUTES ) + # None means "leave alone". Cross-field validity depends on the merged state, + # so it is checked in the service rather than here. + schedule_mode: Optional[Literal["interval", "daily", "weekly"]] = None + schedule_hours: Optional[List[int]] = None + schedule_days: Optional[List[int]] = None + schedule_minute: Optional[int] = Field(default=None, ge=0, le=59) max_notes_count: Optional[int] = Field(default=None, ge=1, le=500) enable_comments: Optional[bool] = None max_comments_count: Optional[int] = Field(default=None, ge=1, le=500) diff --git a/tests/test_schedule.py b/tests/test_schedule.py new file mode 100644 index 0000000..d862659 --- /dev/null +++ b/tests/test_schedule.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +"""Tests for monitor task schedule arithmetic. + +Everything here is timezone-local, matching the implementation: the container is +pinned to the operator's zone via TZ, so the tests build their expectations from +naive local datetimes too and stay correct wherever they run. +""" + +from datetime import datetime, time, timedelta + +import pytest + +from api.monitor import schedule + + +def _ms(moment: datetime) -> int: + return int(moment.timestamp() * 1000) + + +def test_interval_is_now_plus_the_interval(): + after = _ms(datetime(2026, 10, 7, 9, 0)) + + nxt = schedule.next_occurrence( + mode=schedule.MODE_INTERVAL, + interval_minutes=120, + hours=[], + days=[], + minute=0, + after_ms=after, + ) + + assert nxt == after + 120 * 60_000 + + +def test_daily_takes_the_soonest_remaining_time_today(): + after = _ms(datetime(2026, 10, 7, 8, 0)) + + nxt = schedule.next_occurrence( + mode=schedule.MODE_DAILY, + interval_minutes=60, + hours=[18, 9], # deliberately unsorted + days=[], + minute=30, + after_ms=after, + ) + + assert nxt == _ms(datetime(2026, 10, 7, 9, 30)) + + +def test_daily_rolls_over_to_tomorrow_once_every_time_has_passed(): + after = _ms(datetime(2026, 10, 7, 20, 0)) + + nxt = schedule.next_occurrence( + mode=schedule.MODE_DAILY, + interval_minutes=60, + hours=[9, 18], + days=[], + minute=30, + after_ms=after, + ) + + assert nxt == _ms(datetime(2026, 10, 8, 9, 30)) + + +def test_a_slot_exactly_now_belongs_to_the_next_day(): + """Strictly-after, so the run that just fired does not fire again. + + The scheduler advances with ``after_ms`` set to the moment the run started, + which is at or just past the slot -- if the comparison were inclusive it would + pick the same slot back up and loop. + """ + after = _ms(datetime(2026, 10, 7, 9, 30)) + + nxt = schedule.next_occurrence( + mode=schedule.MODE_DAILY, + interval_minutes=60, + hours=[9], + days=[], + minute=30, + after_ms=after, + ) + + assert nxt == _ms(datetime(2026, 10, 8, 9, 30)) + + +def test_weekly_jumps_to_the_next_selected_weekday(): + after_dt = datetime(2026, 10, 7, 8, 0) + target = (after_dt.weekday() + 2) % 7 + + nxt = schedule.next_occurrence( + mode=schedule.MODE_WEEKLY, + interval_minutes=60, + hours=[10], + days=[target], + minute=0, + after_ms=_ms(after_dt), + ) + + expected = datetime.combine((after_dt + timedelta(days=2)).date(), time(10, 0)) + assert nxt == _ms(expected) + + +def test_weekly_can_fire_later_the_same_day(): + after_dt = datetime(2026, 10, 7, 8, 0) + + nxt = schedule.next_occurrence( + mode=schedule.MODE_WEEKLY, + interval_minutes=60, + hours=[21], + days=[after_dt.weekday()], + minute=15, + after_ms=_ms(after_dt), + ) + + assert nxt == _ms(datetime.combine(after_dt.date(), time(21, 15))) + + +def test_weekly_without_weekdays_means_every_day(): + """Otherwise an empty day selection would match nothing and never fire.""" + after = _ms(datetime(2026, 10, 7, 8, 0)) + + nxt = schedule.next_occurrence( + mode=schedule.MODE_WEEKLY, + interval_minutes=60, + hours=[9], + days=[], + minute=0, + after_ms=after, + ) + + assert nxt == _ms(datetime(2026, 10, 7, 9, 0)) + + +def test_a_clock_schedule_with_no_times_can_never_fire(): + """Returned as None so the caller can park the task instead of leaving it due.""" + nxt = schedule.next_occurrence( + mode=schedule.MODE_DAILY, + interval_minutes=60, + hours=[], + days=[], + minute=0, + after_ms=_ms(datetime(2026, 10, 7, 8, 0)), + ) + + assert nxt is None + + +@pytest.mark.parametrize( + "raw, expected", + [ + ("9,18", [9, 18]), + ("18,9", [9, 18]), # stored order is not guaranteed + ("9,9,9", [9]), + ("", []), + (None, []), + ("9, 18 ", [9, 18]), + ("9,99,-1,abc,", [9]), # junk is dropped, never raised + ], +) +def test_parse_hours_is_forgiving(raw, expected): + assert schedule.parse_hours(raw) == expected + + +def test_parse_days_accepts_the_whole_week(): + assert schedule.parse_days("0,1,2,3,4,5,6") == [0, 1, 2, 3, 4, 5, 6] + assert schedule.parse_days("7,-1") == [] + + +@pytest.mark.parametrize( + "mode, interval, hours, days, minute, expected", + [ + (schedule.MODE_INTERVAL, 360, [], [], 0, "每 6 小时"), + (schedule.MODE_INTERVAL, 1440, [], [], 0, "每 1 天"), + (schedule.MODE_INTERVAL, 45, [], [], 0, "每 45 分钟"), + (schedule.MODE_DAILY, 60, [9, 18], [], 30, "每天 09:30、18:30"), + (schedule.MODE_WEEKLY, 60, [10], [0, 1, 2, 3, 4], 0, "周一、周二、周三、周四、周五 10:00"), + (schedule.MODE_WEEKLY, 60, [10], [], 0, "每天 10:00"), + (schedule.MODE_DAILY, 60, [], [], 0, "未设置时间"), + ], +) +def test_describe(mode, interval, hours, days, minute, expected): + assert ( + schedule.describe( + mode=mode, interval_minutes=interval, hours=hours, days=days, minute=minute + ) + == expected + ) + + +def test_format_round_trips_through_parse(): + hours = [9, 12, 18] + days = [0, 4] + assert schedule.parse_hours(schedule.format_hours(hours)) == hours + assert schedule.parse_days(schedule.format_days(days)) == days diff --git a/webui/src/components/monitor/TaskCard.tsx b/webui/src/components/monitor/TaskCard.tsx index f694f14..e370813 100644 --- a/webui/src/components/monitor/TaskCard.tsx +++ b/webui/src/components/monitor/TaskCard.tsx @@ -3,7 +3,7 @@ import { Bell, CalendarClock, Pencil, Play, Trash2 } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { useDeleteTask, useRunTaskNow, useUpdateTask } from '@/hooks/useMonitor' -import { formatInterval, formatRelative } from '@/lib/monitorFormat' +import { formatRelative } from '@/lib/monitorFormat' import type { MonitorTask } from '@/types/monitor' interface TaskCardProps { @@ -82,7 +82,8 @@ export function TaskCard({ task, selected, onSelect, onEdit }: TaskCardProps) { 目标 {task.target_count} 个 - 间隔 {formatInterval(task.interval_minutes)} + {/* 由后端拼好,列表和编辑弹窗因此不会对同一个计划给出两种说法 */} + {task.schedule_label} {task.enabled ? formatRelative(task.next_run_at) : '已暂停'} diff --git a/webui/src/components/monitor/TaskEditorDialog.tsx b/webui/src/components/monitor/TaskEditorDialog.tsx index e28a92c..388360f 100644 --- a/webui/src/components/monitor/TaskEditorDialog.tsx +++ b/webui/src/components/monitor/TaskEditorDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, type ReactNode } from 'react' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' @@ -20,7 +20,13 @@ import { SelectValue, } from '@/components/ui/select' import { useCreateTask, useSettings, useUpdateTask } from '@/hooks/useMonitor' -import type { MonitorMode, MonitorTask, TaskCreatePayload } from '@/types/monitor' +import { describeSchedule } from '@/lib/monitorFormat' +import type { + MonitorMode, + MonitorTask, + ScheduleMode, + TaskCreatePayload, +} from '@/types/monitor' interface TaskEditorDialogProps { open: boolean @@ -44,6 +50,48 @@ const INTERVAL_OPTIONS = [ { value: '10080', label: '7 天' }, ] +const SCHEDULE_MODE_OPTIONS: Array<{ value: ScheduleMode; label: string }> = [ + { value: 'interval', label: '固定间隔' }, + { value: 'daily', label: '每天定时' }, + { value: 'weekly', label: '每周定时' }, +] + +const HOURS = Array.from({ length: 24 }, (_, hour) => hour) +const MINUTES = Array.from({ length: 60 }, (_, minute) => minute) +const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] +const pad = (value: number) => String(value).padStart(2, '0') + +function toggleNumber(values: number[], value: number): number[] { + return values.includes(value) + ? values.filter((item) => item !== value) + : [...values, value].sort((a, b) => a - b) +} + +/** A selectable pill. Used for the hours of the day and the weekdays. */ +function Chip({ + active, + onClick, + children, +}: { + active: boolean + onClick: () => void + children: ReactNode +}) { + return ( + + ) +} + export function TaskEditorDialog({ open, onOpenChange, task }: TaskEditorDialogProps) { const isEdit = Boolean(task) const createTask = useCreateTask() @@ -53,6 +101,10 @@ export function TaskEditorDialog({ open, onOpenChange, task }: TaskEditorDialogP const [name, setName] = useState('') const [mode, setMode] = useState('creator') const [intervalMinutes, setIntervalMinutes] = useState('360') + const [scheduleMode, setScheduleMode] = useState('interval') + const [scheduleHours, setScheduleHours] = useState([]) + const [scheduleDays, setScheduleDays] = useState([]) + const [scheduleMinute, setScheduleMinute] = useState('0') const [maxNotes, setMaxNotes] = useState('20') const [enableComments, setEnableComments] = useState(true) const [maxComments, setMaxComments] = useState('50') @@ -69,6 +121,10 @@ export function TaskEditorDialog({ open, onOpenChange, task }: TaskEditorDialogP setIntervalMinutes( String(task?.interval_minutes ?? settings?.values['collect.default_interval_minutes'] ?? 360), ) + setScheduleMode(task?.schedule_mode ?? 'interval') + setScheduleHours(task?.schedule_hours ?? []) + setScheduleDays(task?.schedule_days ?? []) + setScheduleMinute(String(task?.schedule_minute ?? 0)) setMaxNotes( String(task?.max_notes_count ?? settings?.values['collect.default_max_notes'] ?? 20), ) @@ -85,17 +141,35 @@ export function TaskEditorDialog({ open, onOpenChange, task }: TaskEditorDialogP .map((value) => value.trim()) .filter(Boolean) + // Switching into a clock mode with nothing chosen would be invalid, so seed a + // sensible starting point -- the operator then edits rather than fills a blank. + const selectScheduleMode = (next: ScheduleMode) => { + setScheduleMode(next) + if (next !== 'interval' && scheduleHours.length === 0) setScheduleHours([9]) + if (next === 'weekly' && scheduleDays.length === 0) setScheduleDays([0, 1, 2, 3, 4]) + } + const pending = createTask.isPending || updateTask.isPending // Note-mode and creator-mode targets are different shapes, so switching mode // would silently mis-parse the list. The backend decides parsing from the // task's stored mode, hence mode is fixed once created. - const canSubmit = name.trim().length > 0 && targetList.length > 0 && !pending + const clockIncomplete = + scheduleMode !== 'interval' && + (scheduleHours.length === 0 || (scheduleMode === 'weekly' && scheduleDays.length === 0)) + const canSubmit = + name.trim().length > 0 && targetList.length > 0 && !pending && !clockIncomplete const handleSubmit = () => { const payload: TaskCreatePayload = { name: name.trim(), mode, interval_minutes: Number(intervalMinutes), + schedule_mode: scheduleMode, + // Interval mode has no clock times, so send none rather than leave stale + // ones behind from a mode the operator tried and abandoned. + schedule_hours: scheduleMode === 'interval' ? [] : scheduleHours, + schedule_days: scheduleMode === 'weekly' ? scheduleDays : [], + schedule_minute: Number(scheduleMinute), max_notes_count: Number(maxNotes), enable_comments: enableComments, max_comments_count: Number(maxComments), @@ -159,6 +233,28 @@ export function TaskEditorDialog({ open, onOpenChange, task }: TaskEditorDialogP )} +
+ +
+ {SCHEDULE_MODE_OPTIONS.map((option) => ( + + ))} +
+
+ + + {scheduleMode === 'interval' ? (
+

+ 从上一轮开始计时, + 所以某轮跑得久也不会让下一轮紧接着触发。 +

- + ) : ( +
+ {scheduleMode === 'weekly' && ( +
+
星期
+
+ {WEEKDAYS.map((label, day) => ( + setScheduleDays((prev) => toggleNumber(prev, day))} + > + {label} + + ))} +
+
+ )} + +
+
+ 时间(可多选,点一次选中、再点取消) +
+
+ {HOURS.map((hour) => ( + setScheduleHours((prev) => toggleNumber(prev, hour))} + > + {pad(hour)} + + ))} +
+
+ +
+ 分钟 + + + 所有时间点共用 + +
+ +
+ + {describeSchedule( + scheduleMode, + Number(intervalMinutes), + scheduleHours, + scheduleDays, + Number(scheduleMinute), + )} + + + 按服务器本地时间 + +
+
+ )}