feat(schedule): 任务支持「每天定时 / 每周定时」,用选择器而不是手写 cron
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

- schedule.py: 新增调度计算模块(纯函数,便于单测)。三种模式:interval(每 N 分钟)/ daily(选钟点)/ weekly(选星期 + 钟点)
- 钟点模式是「固定时刻」而非「固定延迟」——从日历重算,所以某轮跑晚了不会把之后每一轮都拖晚。interval 保持原语义:从上一轮开始计时
- 抖动只加给 interval。给「每天 9:00」也加抖动就成了 9:00–9:01 随机触发,操作者选的时间被悄悄改掉,只会像 bug
- 模型 / schema / service: 新增 schedule_mode / schedule_hours / schedule_days / schedule_minute。时钟字段存逗号分隔文本——几个小整数、永远整体读写,单开一张表只会换来 join。interval_minutes 保留且仍是默认值,已有任务不受影响
- service: 改动任何调度字段都按合并后的状态重算 next_run_at。重新启用也算改动,否则停用一个月再打开会带着一个月前的 next_run_at,一保存就立即触发
- 前端: 运行方式三选一 + 小时/星期胶囊多选 + 分钟下拉,并实时预览结果句子。任务卡片改显示后端拼好的 schedule_label,避免列表和编辑器对同一计划给出两种说法
- 校验: 钟点模式至少选一个时间,按周至少选一个星期

tests/test_schedule.py 新增 24 个用例,含「恰好等于当前时刻的档位归属下一天」这个会让调度器自循环的边界。
This commit is contained in:
2026-10-07 15:33:17 +08:00
parent 242e3a7837
commit 8f4e5586e9
10 changed files with 776 additions and 22 deletions
+16 -1
View File
@@ -75,7 +75,7 @@ MODE_NOTE = "note"
class MonitorTask(MonitorBase): 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" __tablename__ = "monitor_task"
@@ -86,6 +86,21 @@ class MonitorTask(MonitorBase):
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=360) 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. # Crawl window knobs, mirrored onto each run's CLI flags.
max_notes_count: Mapped[int] = mapped_column(Integer, nullable=False, default=20) max_notes_count: Mapped[int] = mapped_column(Integer, nullable=False, default=20)
enable_comments: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) enable_comments: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+167
View File
@@ -0,0 +1,167 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2025 [email protected]
#
# 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
+38 -9
View File
@@ -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 therefore no concurrency to coordinate -- a cron-style library would add a
dependency without adding a capability. dependency without adding a capability.
Scheduling is **fixed-delay**, not fixed-rate: ``next_run_at`` is set from the Two families of schedule, and the difference matters:
moment a run starts, so a slow run cannot make its task fire back-to-back.
* ``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 import asyncio
@@ -37,7 +44,7 @@ from sqlalchemy import select
from tools.time_util import get_current_timestamp from tools.time_util import get_current_timestamp
from ..services import crawler_manager from ..services import crawler_manager
from . import app_settings from . import app_settings, schedule
from .db import get_session from .db import get_session
from .models import MonitorRun, MonitorTask, RUN_INTERRUPTED, RUN_RUNNING from .models import MonitorRun, MonitorTask, RUN_INTERRUPTED, RUN_RUNNING
from .runner import execute_task from .runner import execute_task
@@ -45,10 +52,9 @@ from .settings import get_cookie
POLL_INTERVAL_SECONDS = 20 POLL_INTERVAL_SECONDS = 20
# Spread tasks sharing an interval so they do not all come due on the same tick. # 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 JITTER_SECONDS = 60
_MS_PER_MINUTE = 60_000
class MonitorScheduler: class MonitorScheduler:
"""Polls the task table and runs whatever is due.""" """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 # 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 # re-fire, and so a long outage coalesces into a single run instead
# of one run per missed interval. # of one run per missed interval.
task.next_run_at = ( now = get_current_timestamp()
get_current_timestamp() following = schedule.next_occurrence(
+ task.interval_minutes * _MS_PER_MINUTE mode=task.schedule_mode,
+ random.randint(0, JITTER_SECONDS) * 1000 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 task_id = task.id
try: try:
+93 -5
View File
@@ -28,7 +28,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from tools.time_util import get_current_timestamp 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 .db import get_session
from .platforms import PLATFORM_XHS from .platforms import PLATFORM_XHS
from .models import ( from .models import (
@@ -53,6 +53,28 @@ _background_runs: set[asyncio.Task] = set()
MIN_INTERVAL_MINUTES = 30 MIN_INTERVAL_MINUTES = 30
MAX_INTERVAL_MINUTES = 7 * 24 * 60 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_-]+)") _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_-]+)") _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 # 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. # the Settings page actually governs new tasks.
defaults = await app_settings.defaults(session, platform) defaults = await app_settings.defaults(session, platform)
interval_minutes = payload.get("interval_minutes") or defaults["interval_minutes"] 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( task = MonitorTask(
name=payload["name"], name=payload["name"],
@@ -147,12 +174,23 @@ async def create_task(session: AsyncSession, payload: Dict[str, Any]) -> Monitor
mode=mode, mode=mode,
enabled=payload.get("enabled", True), enabled=payload.get("enabled", True),
interval_minutes=interval_minutes, 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"], max_notes_count=payload.get("max_notes_count") or defaults["max_notes_count"],
enable_comments=payload.get("enable_comments", True), enable_comments=payload.get("enable_comments", True),
max_comments_count=payload.get("max_comments_count") or defaults["max_comments_count"], max_comments_count=payload.get("max_comments_count") or defaults["max_comments_count"],
run_timeout_seconds=payload.get("run_timeout_seconds", 3600), run_timeout_seconds=payload.get("run_timeout_seconds", 3600),
notify_enabled=payload.get("notify_enabled", False), 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", last_status="idle",
created_at=now, created_at=now,
updated_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: if task is None:
raise ValueError(f"Task {task_id} not found") raise ValueError(f"Task {task_id} not found")
was_enabled = task.enabled
for field in ( for field in (
"name", "name",
"enabled", "enabled",
"interval_minutes", "interval_minutes",
"schedule_mode",
"schedule_minute",
"max_notes_count", "max_notes_count",
"enable_comments", "enable_comments",
"max_comments_count", "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: if field in payload and payload[field] is not None:
setattr(task, field, payload[field]) 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 # Replacing targets resets the baseline implicitly: a note set that now
# includes new ids will simply report them as new on the next run. # includes new ids will simply report them as new on the next run.
if payload.get("targets") is not None: 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"]: # Any change to when the task runs invalidates the pending slot, so recompute
task.next_run_at = get_current_timestamp() + payload["interval_minutes"] * 60_000 # 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() task.updated_at = get_current_timestamp()
await session.flush() await session.flush()
@@ -623,6 +687,7 @@ async def list_tasks(
"mode": task.mode, "mode": task.mode,
"enabled": task.enabled, "enabled": task.enabled,
"interval_minutes": task.interval_minutes, "interval_minutes": task.interval_minutes,
**_schedule_fields(task),
"max_notes_count": task.max_notes_count, "max_notes_count": task.max_notes_count,
"enable_comments": task.enable_comments, "enable_comments": task.enable_comments,
"max_comments_count": task.max_comments_count, "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]: async def overview(session: AsyncSession, platform: Optional[str] = None) -> Dict[str, Any]:
"""Headline numbers for the dashboard tiles, scoped to one platform.""" """Headline numbers for the dashboard tiles, scoped to one platform."""
now = get_current_timestamp() now = get_current_timestamp()
+34 -1
View File
@@ -20,7 +20,7 @@
from typing import List, Literal, Optional 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 # 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 # 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( interval_minutes: Optional[int] = Field(
default=None, ge=MIN_INTERVAL_MINUTES, le=MAX_INTERVAL_MINUTES 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) max_notes_count: Optional[int] = Field(default=None, ge=1, le=500)
enable_comments: bool = True enable_comments: bool = True
# Raising this widens the comment window, which is the only lever available # 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. # Raw pasted values: full URLs or bare ids, in either form.
targets: List[str] = Field(min_length=1) 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): class MonitorTaskUpdate(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=200) name: Optional[str] = Field(default=None, min_length=1, max_length=200)
@@ -60,6 +87,12 @@ class MonitorTaskUpdate(BaseModel):
interval_minutes: Optional[int] = Field( interval_minutes: Optional[int] = Field(
default=None, ge=MIN_INTERVAL_MINUTES, le=MAX_INTERVAL_MINUTES 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) max_notes_count: Optional[int] = Field(default=None, ge=1, le=500)
enable_comments: Optional[bool] = None enable_comments: Optional[bool] = None
max_comments_count: Optional[int] = Field(default=None, ge=1, le=500) max_comments_count: Optional[int] = Field(default=None, ge=1, le=500)
+194
View File
@@ -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
+3 -2
View File
@@ -3,7 +3,7 @@ import { Bell, CalendarClock, Pencil, Play, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useDeleteTask, useRunTaskNow, useUpdateTask } from '@/hooks/useMonitor' import { useDeleteTask, useRunTaskNow, useUpdateTask } from '@/hooks/useMonitor'
import { formatInterval, formatRelative } from '@/lib/monitorFormat' import { formatRelative } from '@/lib/monitorFormat'
import type { MonitorTask } from '@/types/monitor' import type { MonitorTask } from '@/types/monitor'
interface TaskCardProps { interface TaskCardProps {
@@ -82,7 +82,8 @@ export function TaskCard({ task, selected, onSelect, onEdit }: TaskCardProps) {
<span> <span>
目标 <span className="text-cyber-neon-cyan">{task.target_count}</span> 个 目标 <span className="text-cyber-neon-cyan">{task.target_count}</span> 个
</span> </span>
<span>间隔 {formatInterval(task.interval_minutes)}</span> {/* 由后端拼好,列表和编辑弹窗因此不会对同一个计划给出两种说法 */}
<span>{task.schedule_label}</span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<CalendarClock className="w-3 h-3" /> <CalendarClock className="w-3 h-3" />
{task.enabled ? formatRelative(task.next_run_at) : '已暂停'} {task.enabled ? formatRelative(task.next_run_at) : '已暂停'}
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useState, type ReactNode } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
@@ -20,7 +20,13 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { useCreateTask, useSettings, useUpdateTask } from '@/hooks/useMonitor' 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 { interface TaskEditorDialogProps {
open: boolean open: boolean
@@ -44,6 +50,48 @@ const INTERVAL_OPTIONS = [
{ value: '10080', label: '7 天' }, { 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 (
<button
type="button"
onClick={onClick}
className={`px-1.5 py-0.5 rounded border text-[10px] font-mono transition-colors ${
active
? 'border-cyber-neon-cyan/60 bg-cyber-neon-cyan/20 text-cyber-neon-cyan'
: 'border-cyber-border-DEFAULT text-cyber-text-muted hover:text-cyber-text-secondary'
}`}
>
{children}
</button>
)
}
export function TaskEditorDialog({ open, onOpenChange, task }: TaskEditorDialogProps) { export function TaskEditorDialog({ open, onOpenChange, task }: TaskEditorDialogProps) {
const isEdit = Boolean(task) const isEdit = Boolean(task)
const createTask = useCreateTask() const createTask = useCreateTask()
@@ -53,6 +101,10 @@ export function TaskEditorDialog({ open, onOpenChange, task }: TaskEditorDialogP
const [name, setName] = useState('') const [name, setName] = useState('')
const [mode, setMode] = useState<MonitorMode>('creator') const [mode, setMode] = useState<MonitorMode>('creator')
const [intervalMinutes, setIntervalMinutes] = useState('360') const [intervalMinutes, setIntervalMinutes] = useState('360')
const [scheduleMode, setScheduleMode] = useState<ScheduleMode>('interval')
const [scheduleHours, setScheduleHours] = useState<number[]>([])
const [scheduleDays, setScheduleDays] = useState<number[]>([])
const [scheduleMinute, setScheduleMinute] = useState('0')
const [maxNotes, setMaxNotes] = useState('20') const [maxNotes, setMaxNotes] = useState('20')
const [enableComments, setEnableComments] = useState(true) const [enableComments, setEnableComments] = useState(true)
const [maxComments, setMaxComments] = useState('50') const [maxComments, setMaxComments] = useState('50')
@@ -69,6 +121,10 @@ export function TaskEditorDialog({ open, onOpenChange, task }: TaskEditorDialogP
setIntervalMinutes( setIntervalMinutes(
String(task?.interval_minutes ?? settings?.values['collect.default_interval_minutes'] ?? 360), 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( setMaxNotes(
String(task?.max_notes_count ?? settings?.values['collect.default_max_notes'] ?? 20), 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()) .map((value) => value.trim())
.filter(Boolean) .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 const pending = createTask.isPending || updateTask.isPending
// Note-mode and creator-mode targets are different shapes, so switching mode // Note-mode and creator-mode targets are different shapes, so switching mode
// would silently mis-parse the list. The backend decides parsing from the // would silently mis-parse the list. The backend decides parsing from the
// task's stored mode, hence mode is fixed once created. // 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 handleSubmit = () => {
const payload: TaskCreatePayload = { const payload: TaskCreatePayload = {
name: name.trim(), name: name.trim(),
mode, mode,
interval_minutes: Number(intervalMinutes), 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), max_notes_count: Number(maxNotes),
enable_comments: enableComments, enable_comments: enableComments,
max_comments_count: Number(maxComments), max_comments_count: Number(maxComments),
@@ -159,6 +233,28 @@ export function TaskEditorDialog({ open, onOpenChange, task }: TaskEditorDialogP
)} )}
</div> </div>
<div className="space-y-2">
<Label className="text-xs font-mono text-cyber-text-secondary">运行计划</Label>
<div className="flex gap-1">
{SCHEDULE_MODE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => selectScheduleMode(option.value)}
className={`flex-1 rounded border px-1 py-1.5 text-[10px] font-mono transition-colors ${
scheduleMode === option.value
? 'border-cyber-neon-cyan/60 bg-cyber-neon-cyan/20 text-cyber-neon-cyan'
: 'border-cyber-border-DEFAULT text-cyber-text-muted hover:text-cyber-text-secondary'
}`}
>
{option.label}
</button>
))}
</div>
</div>
</div>
{scheduleMode === 'interval' ? (
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-mono text-cyber-text-secondary">采集间隔</Label> <Label className="text-xs font-mono text-cyber-text-secondary">采集间隔</Label>
<Select value={intervalMinutes} onValueChange={setIntervalMinutes}> <Select value={intervalMinutes} onValueChange={setIntervalMinutes}>
@@ -173,8 +269,82 @@ export function TaskEditorDialog({ open, onOpenChange, task }: TaskEditorDialogP
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-[10px] font-mono text-cyber-text-muted">
从上一轮<span className="text-cyber-text-secondary">开始</span>计时,
所以某轮跑得久也不会让下一轮紧接着触发。
</p>
</div> </div>
</div> ) : (
<div className="space-y-3 rounded-md border border-cyber-border-subtle bg-cyber-bg-tertiary/40 p-3">
{scheduleMode === 'weekly' && (
<div className="space-y-1.5">
<div className="text-[10px] font-mono text-cyber-text-secondary">星期</div>
<div className="flex flex-wrap gap-1">
{WEEKDAYS.map((label, day) => (
<Chip
key={day}
active={scheduleDays.includes(day)}
onClick={() => setScheduleDays((prev) => toggleNumber(prev, day))}
>
{label}
</Chip>
))}
</div>
</div>
)}
<div className="space-y-1.5">
<div className="text-[10px] font-mono text-cyber-text-secondary">
时间(可多选,点一次选中、再点取消)
</div>
<div className="flex flex-wrap gap-1">
{HOURS.map((hour) => (
<Chip
key={hour}
active={scheduleHours.includes(hour)}
onClick={() => setScheduleHours((prev) => toggleNumber(prev, hour))}
>
{pad(hour)}
</Chip>
))}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-cyber-text-secondary">分钟</span>
<Select value={scheduleMinute} onValueChange={setScheduleMinute}>
<SelectTrigger className="h-7 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-56">
{MINUTES.map((minute) => (
<SelectItem key={minute} value={String(minute)}>
{pad(minute)}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[10px] font-mono text-cyber-text-muted">
所有时间点共用
</span>
</div>
<div className="flex items-center justify-between gap-3 border-t border-cyber-border-subtle pt-2">
<span className="text-[11px] font-mono text-cyber-neon-cyan">
{describeSchedule(
scheduleMode,
Number(intervalMinutes),
scheduleHours,
scheduleDays,
Number(scheduleMinute),
)}
</span>
<span className="text-[10px] font-mono text-cyber-text-muted">
按服务器本地时间
</span>
</div>
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-mono text-cyber-text-secondary"> <Label className="text-xs font-mono text-cyber-text-secondary">
+36
View File
@@ -1,5 +1,7 @@
/** Formatting helpers for the monitoring dashboard. */ /** Formatting helpers for the monitoring dashboard. */
import type { ScheduleMode } from '@/types/monitor'
/** Compact count for display: mirrors how the platform itself abbreviates. */ /** Compact count for display: mirrors how the platform itself abbreviates. */
export function formatCount(value: number | null | undefined): string { export function formatCount(value: number | null | undefined): string {
if (value === null || value === undefined) return '—' if (value === null || value === undefined) return '—'
@@ -42,3 +44,37 @@ export function formatInterval(minutes: number): string {
if (minutes % 60 === 0) return `${minutes / 60} 小时` if (minutes % 60 === 0) return `${minutes / 60} 小时`
return `${minutes} 分钟` return `${minutes} 分钟`
} }
const WEEKDAY_NAMES = '一二三四五六日'
/**
* Preview of a schedule, for the editor only.
*
* The task list renders the backend's own `schedule_label` instead. This exists
* so the current selection can be read back before it is saved -- which means the
* two are the same sentence written twice, and a change to one belongs in both.
*/
export function describeSchedule(
mode: ScheduleMode,
intervalMinutes: number,
hours: number[],
days: number[],
minute: number,
): string {
const pad = (value: number) => String(value).padStart(2, '0')
const sorted = (values: number[]) => [...values].sort((a, b) => a - b)
if (mode === 'interval') return `每 ${formatInterval(intervalMinutes)}`
if (hours.length === 0) return '未设置时间'
const clock = sorted(hours)
.map((hour) => `${pad(hour)}:${pad(minute)}`)
.join('、')
if (mode === 'daily' || days.length === 0) return `每天 ${clock}`
const labels = sorted(days)
.map((day) => `周${WEEKDAY_NAMES[day]}`)
.join('、')
return `${labels} ${clock}`
}
+21
View File
@@ -28,6 +28,14 @@ export interface MonitorTarget {
enabled: boolean enabled: boolean
} }
/**
* How a task is scheduled.
*
* All three are expressible with pickers. A cron string is deliberately not
* supported -- it is a small language to learn just to say "every day at nine".
*/
export type ScheduleMode = 'interval' | 'daily' | 'weekly'
export interface MonitorTask { export interface MonitorTask {
id: number id: number
name: string name: string
@@ -35,6 +43,15 @@ export interface MonitorTask {
mode: MonitorMode mode: MonitorMode
enabled: boolean enabled: boolean
interval_minutes: number interval_minutes: number
schedule_mode: ScheduleMode
/** 0-23. Empty in interval mode. */
schedule_hours: number[]
/** 0-6 with Monday = 0, matching Python's `date.weekday()`. Weekly only. */
schedule_days: number[]
/** Minute past the hour, shared by every time in the schedule. */
schedule_minute: number
/** Composed by the backend, so the list and the editor cannot disagree. */
schedule_label: string
max_notes_count: number max_notes_count: number
enable_comments: boolean enable_comments: boolean
max_comments_count: number max_comments_count: number
@@ -195,6 +212,10 @@ export interface TaskCreatePayload {
name: string name: string
mode: MonitorMode mode: MonitorMode
interval_minutes: number interval_minutes: number
schedule_mode: ScheduleMode
schedule_hours: number[]
schedule_days: number[]
schedule_minute: number
max_notes_count: number max_notes_count: number
enable_comments: boolean enable_comments: boolean
max_comments_count: number max_comments_count: number