@cryptotaxi247 / CoPilot / commits / 219d466b

feat(snapshots): per-schedule time-of-day and day-interval gating (#826)

* feat(snapshots): per-schedule time-of-day and day-interval gating Closes #810 Adds four columns to index_snapshot_schedules: - scheduled_hour (0-23, NULL = any hour, legacy behavior preserved) - scheduled_minute (0-59, NULL = any minute) - interval_days (default 1, minimum days between executions) - timezone (IANA name, default UTC) execute_snapshot_schedule() now runs a cheap _is_schedule_due() check before any indexer API calls. Out-of-window schedules log "DEFERRED: ..." and update last_execution_status without touching the Wazuh repository, eliminating off-hours indexer load while preserving the existing "no new indices = SKIPPED" dedup logic. Master invoke_snapshot_schedules poll bumped from 60min to 15min so the scheduled_minute tolerance window has tight enough resolution. Frontend: SnapshotScheduleForm gains hour/minute/interval/timezone inputs; SnapshotSchedules table gains a Schedule column and tags DEFERRED status as a warning alongside SKIPPED. Backward-compatible: existing rows default to scheduled_hour=NULL / interval_days=1 / timezone=UTC and behave exactly as before. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(snapshots): document schedule window fields and Sunday 01:00 CST example Adds a "Schedule window" section to indices-snapshots.md covering the new scheduled_hour / scheduled_minute / interval_days / timezone fields, plus a worked example for pinning a snapshot schedule to Sundays at 01:00 US Central (America/Chicago) including the manual anchor step needed because the v1 schema has no explicit day-of-week field. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * precommit-fixes * refactor(schedule): improve logging and simplify time checks in schedule validation Co-authored-by: Copilot <copilot@github.com> * feat(schedule): add day_of_week field to SnapshotSchedule for scheduling flexibility * feat(snapshots): wire day_of_week through schemas, gate, frontend, docs Builds on the day_of_week column added to index_snapshot_schedules. Backend: - Adds day_of_week to SnapshotScheduleUpdate (clearable via __fields_set__) and SnapshotScheduleResponse so the value round-trips through the API. - Adds a weekday gate to _is_schedule_due() between the interval_days and time-of-day checks. Uses Python convention (Monday=0 ... Sunday=6) to match datetime.weekday(). Combines with interval_days for patterns like "every other Sunday". - Threads day_of_week through create_snapshot_schedule and update_snapshot_schedule. Frontend: - snapshots.d.ts: day_of_week on Create/Update/Response. - SnapshotScheduleForm.vue: Day of Week n-select above Scheduled Hour. - SnapshotSchedules.vue: list view Schedule column now shows "Sundays at 01:00" / "Sundays every 14 days at 01:00" when day_of_week is set. Docs: - indices-snapshots.md: Sunday 01:00 US Central example simplified to one step (no last_execution_time anchor needed). Added a second worked example for "every other Sunday at 01:00 Central" using interval_days=14. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(schedule): correct formatting in schedule due message for clarity * refactor(schedule): simplify deferred scheduling message logging Co-authored-by: Copilot <copilot@github.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Copilot <copilot@github.com>

taylor_socfortress committed Apr 26, 2026 at 17:36 UTC 219d466b3b1a238149e65da102a5519a0b82b64d
10 files changed +507 -10
backend/alembic/versions/2ef4b8b77790_add_to_snapshot_and_restore.py new
+37
@@ -0,0 +1,37 @@
1 +"""Add to snapshot and restore
2 +
3 +Revision ID: 2ef4b8b77790
4 +Revises: 980bb08dd1cd
5 +Create Date: 2026-04-26 17:02:24.164444
6 +
7 +"""
8 +from typing import Sequence
9 +from typing import Union
10 +
11 +import sqlalchemy as sa
12 +
13 +from alembic import op
14 +
15 +# revision identifiers, used by Alembic.
16 +revision: str = "2ef4b8b77790"
17 +down_revision: Union[str, None] = "980bb08dd1cd"
18 +branch_labels: Union[str, Sequence[str], None] = None
19 +depends_on: Union[str, Sequence[str], None] = None
20 +
21 +
22 +def upgrade() -> None:
23 + # ### commands auto generated by Alembic - please adjust! ###
24 + op.add_column("index_snapshot_schedules", sa.Column("scheduled_hour", sa.Integer(), nullable=True))
25 + op.add_column("index_snapshot_schedules", sa.Column("scheduled_minute", sa.Integer(), nullable=True))
26 + op.add_column("index_snapshot_schedules", sa.Column("interval_days", sa.Integer(), nullable=False))
27 + op.add_column("index_snapshot_schedules", sa.Column("timezone", sa.String(length=50), nullable=False))
28 + # ### end Alembic commands ###
29 +
30 +
31 +def downgrade() -> None:
32 + # ### commands auto generated by Alembic - please adjust! ###
33 + op.drop_column("index_snapshot_schedules", "timezone")
34 + op.drop_column("index_snapshot_schedules", "interval_days")
35 + op.drop_column("index_snapshot_schedules", "scheduled_minute")
36 + op.drop_column("index_snapshot_schedules", "scheduled_hour")
37 + # ### end Alembic commands ###
backend/alembic/versions/51e33a247851_add_to_snapshot_and_restore_day_of_week.py new
+31
@@ -0,0 +1,31 @@
1 +"""Add to snapshot and restore day of week
2 +
3 +Revision ID: 51e33a247851
4 +Revises: 2ef4b8b77790
5 +Create Date: 2026-04-26 17:27:17.439132
6 +
7 +"""
8 +from typing import Sequence
9 +from typing import Union
10 +
11 +import sqlalchemy as sa
12 +
13 +from alembic import op
14 +
15 +# revision identifiers, used by Alembic.
16 +revision: str = "51e33a247851"
17 +down_revision: Union[str, None] = "2ef4b8b77790"
18 +branch_labels: Union[str, Sequence[str], None] = None
19 +depends_on: Union[str, Sequence[str], None] = None
20 +
21 +
22 +def upgrade() -> None:
23 + # ### commands auto generated by Alembic - please adjust! ###
24 + op.add_column("index_snapshot_schedules", sa.Column("day_of_week", sa.Integer(), nullable=True))
25 + # ### end Alembic commands ###
26 +
27 +
28 +def downgrade() -> None:
29 + # ### commands auto generated by Alembic - please adjust! ###
30 + op.drop_column("index_snapshot_schedules", "day_of_week")
31 + # ### end Alembic commands ###
backend/app/connectors/wazuh_indexer/models/snapshot_and_restore.py
+30
@@ -19,5 +19,35 @@ class SnapshotSchedule(SQLModel, table=True):
19 last_execution_time: Optional[datetime] = Field(default=None, description="Last time this schedule was executed")
20 last_snapshot_name: Optional[str] = Field(default=None, description="Name of the last snapshot created")
21 last_execution_status: Optional[str] = Field(default=None, description="Status of the last execution")
22 + scheduled_hour: Optional[int] = Field(
23 + default=None,
24 + ge=0,
25 + le=23,
26 + description="Hour of day (0-23) when this schedule should run. NULL = any hour (legacy hourly behavior).",
27 + )
28 + scheduled_minute: Optional[int] = Field(
29 + default=None,
30 + ge=0,
31 + le=59,
32 + description="Minute of hour (0-59) when this schedule should run. NULL = any minute.",
33 + )
34 + interval_days: int = Field(
35 + default=1,
36 + ge=1,
37 + description="Minimum number of days between executions. Default 1 = at most once per day.",
38 + )
39 + day_of_week: Optional[int] = Field(
40 + default=None,
41 + ge=0,
42 + le=6,
43 + description=(
44 + "Day of week (0=Monday ... 6=Sunday) when this schedule is allowed to run. "
45 + "NULL = any day. Combines with interval_days for patterns like 'every other Sunday'."
46 + ),
47 + )
48 + timezone: str = Field(
49 + default="UTC",
50 + description="IANA timezone name used to evaluate scheduled_hour/minute (e.g., 'UTC', 'America/New_York').",
51 + )
52 created_at: datetime = Field(default_factory=datetime.utcnow, description="When this schedule was created")
53 updated_at: datetime = Field(default_factory=datetime.utcnow, description="When this schedule was last updated")
backend/app/connectors/wazuh_indexer/schema/snapshot_and_restore.py
+59
@@ -266,6 +266,33 @@ class SnapshotScheduleCreate(BaseModel):
266 None,
267 description="Number of days to retain snapshots (None = forever)",
268 )
269 + scheduled_hour: Optional[int] = Field(
270 + None,
271 + ge=0,
272 + le=23,
273 + description="Hour of day (0-23) when this schedule should run. NULL = any hour.",
274 + )
275 + scheduled_minute: Optional[int] = Field(
276 + None,
277 + ge=0,
278 + le=59,
279 + description="Minute of hour (0-59) when this schedule should run. NULL = any minute.",
280 + )
281 + interval_days: Optional[int] = Field(
282 + 1,
283 + ge=1,
284 + description="Minimum number of days between executions. Default 1 = at most once per day.",
285 + )
286 + day_of_week: Optional[int] = Field(
287 + None,
288 + ge=0,
289 + le=6,
290 + description="Day of week (0=Monday ... 6=Sunday) when this schedule should run. NULL = any day.",
291 + )
292 + timezone: Optional[str] = Field(
293 + "UTC",
294 + description="IANA timezone name used to evaluate scheduled_hour/minute (e.g., 'UTC', 'America/New_York').",
295 + )
296
297
298 class SnapshotScheduleUpdate(BaseModel):
@@ -279,6 +306,33 @@ class SnapshotScheduleUpdate(BaseModel):
306 include_global_state: Optional[bool] = Field(None, description="Include global cluster state")
307 skip_write_indices: Optional[bool] = Field(None, description="Skip indices currently being written to")
308 retention_days: Optional[int] = Field(None, description="Number of days to retain snapshots")
309 + scheduled_hour: Optional[int] = Field(
310 + None,
311 + ge=0,
312 + le=23,
313 + description="Hour of day (0-23) when this schedule should run.",
314 + )
315 + scheduled_minute: Optional[int] = Field(
316 + None,
317 + ge=0,
318 + le=59,
319 + description="Minute of hour (0-59) when this schedule should run.",
320 + )
321 + interval_days: Optional[int] = Field(
322 + None,
323 + ge=1,
324 + description="Minimum number of days between executions.",
325 + )
326 + day_of_week: Optional[int] = Field(
327 + None,
328 + ge=0,
329 + le=6,
330 + description="Day of week (0=Monday ... 6=Sunday) when this schedule should run.",
331 + )
332 + timezone: Optional[str] = Field(
333 + None,
334 + description="IANA timezone name used to evaluate scheduled_hour/minute.",
335 + )
336
337
338 class SnapshotScheduleResponse(BaseModel):
@@ -296,6 +350,11 @@ class SnapshotScheduleResponse(BaseModel):
350 last_execution_time: Optional[str] = Field(None, description="Last execution time")
351 last_snapshot_name: Optional[str] = Field(None, description="Name of the last snapshot created")
352 last_execution_status: Optional[str] = Field(None, description="Status of the last execution")
353 + scheduled_hour: Optional[int] = Field(None, description="Hour of day (0-23) when this schedule runs. NULL = any hour.")
354 + scheduled_minute: Optional[int] = Field(None, description="Minute of hour (0-59) when this schedule runs. NULL = any minute.")
355 + interval_days: int = Field(1, description="Minimum number of days between executions.")
356 + day_of_week: Optional[int] = Field(None, description="Day of week (0=Monday ... 6=Sunday). NULL = any day.")
357 + timezone: str = Field("UTC", description="IANA timezone name used to evaluate scheduled_hour/minute.")
358 created_at: str = Field(..., description="When this schedule was created")
359 updated_at: str = Field(..., description="When this schedule was last updated")
360
backend/app/connectors/wazuh_indexer/services/snapshot_and_restore.py
+120
@@ -2,10 +2,13 @@ import re
2 from collections import defaultdict
3 from datetime import datetime
4 from datetime import timedelta
5 +from datetime import timezone as dt_timezone
6 from typing import Dict
7 from typing import List
8 from typing import Optional
9 from typing import Tuple
10 +from zoneinfo import ZoneInfo
11 +from zoneinfo import ZoneInfoNotFoundError
12
13 from loguru import logger
14 from sqlalchemy import select
@@ -204,6 +207,76 @@ async def filter_write_indices(
207 return indices_to_snapshot, skipped_write_indices
208
209
210 +# Tolerance window (minutes) used when matching scheduled_minute. Should be
211 +# >= the master poll cadence to avoid missing a slot. Master poll runs every
212 +# 15 minutes (see app/schedulers/scheduler.py initialize_job_metadata).
213 +SCHEDULE_MATCH_TOLERANCE_MINUTES = 15
214 +
215 +
216 +def _is_schedule_due(schedule: SnapshotSchedule, now_utc: Optional[datetime] = None) -> Tuple[bool, str]:
217 + """
218 + Determine whether a snapshot schedule is due to run based on its
219 + scheduled_hour, scheduled_minute, interval_days, and timezone fields.
220 +
221 + Returns:
222 + (due, reason). When due is False, reason explains why so it can be
223 + recorded as last_execution_status (e.g., "DEFERRED: outside window").
224 + """
225 + if now_utc is None:
226 + now_utc = datetime.now(dt_timezone.utc)
227 + elif now_utc.tzinfo is None:
228 + now_utc = now_utc.replace(tzinfo=dt_timezone.utc)
229 +
230 + # Resolve the schedule's timezone (fall back to UTC on any issue).
231 + try:
232 + tz = ZoneInfo(schedule.timezone or "UTC")
233 + except ZoneInfoNotFoundError:
234 + logger.warning(
235 + f"Schedule {schedule.name}: invalid timezone '{schedule.timezone}', falling back to UTC",
236 + )
237 + tz = ZoneInfo("UTC")
238 + now_local = now_utc.astimezone(tz)
239 +
240 + # interval_days gate — compare local calendar dates so a schedule with
241 + # interval_days=1 fires at most once per local day.
242 + interval_days = schedule.interval_days or 1
243 + if schedule.last_execution_time is not None:
244 + last_utc = schedule.last_execution_time
245 + if last_utc.tzinfo is None:
246 + last_utc = last_utc.replace(tzinfo=dt_timezone.utc)
247 + last_local_date = last_utc.astimezone(tz).date()
248 + days_elapsed = (now_local.date() - last_local_date).days
249 + if days_elapsed < interval_days:
250 + return (
251 + False,
252 + f"DEFERRED: interval_days={interval_days}, last run {days_elapsed} day(s) ago",
253 + )
254 +
255 + # Day-of-week gate (Python convention: Monday=0 ... Sunday=6).
256 + # Combines with interval_days for patterns like "every other Sunday".
257 + if schedule.day_of_week is not None:
258 + if now_local.weekday() != schedule.day_of_week:
259 + weekday_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
260 + reason = f"DEFERRED: not scheduled day (want={weekday_names[schedule.day_of_week]}, today={weekday_names[now_local.weekday()]} {tz.key})"
261 + logger.info(reason)
262 + return False, reason
263 +
264 + # Time-of-day gate — only enforced when scheduled_hour is set.
265 + if schedule.scheduled_hour is not None:
266 + if now_local.hour != schedule.scheduled_hour:
267 + reason = f"DEFERRED: outside scheduled hour ({schedule.scheduled_hour:02d}:xx {tz.key})"
268 + logger.info(reason)
269 + return False, reason
270 + if schedule.scheduled_minute is not None:
271 + in_window = schedule.scheduled_minute <= now_local.minute < schedule.scheduled_minute + SCHEDULE_MATCH_TOLERANCE_MINUTES
272 + if not in_window:
273 + reason = f"DEFERRED: outside scheduled minute window ({schedule.scheduled_hour:02d}:{schedule.scheduled_minute:02d} +{SCHEDULE_MATCH_TOLERANCE_MINUTES}min {tz.key})"
274 + logger.info(reason)
275 + return False, reason
276 +
277 + return True, ""
278 +
279 +
280 def _schedule_to_response(schedule: SnapshotSchedule) -> SnapshotScheduleResponse:
281 """Convert a SnapshotSchedule model to a response model."""
282 return SnapshotScheduleResponse(
@@ -219,6 +292,11 @@ def _schedule_to_response(schedule: SnapshotSchedule) -> SnapshotScheduleRespons
292 last_execution_time=schedule.last_execution_time.isoformat() if schedule.last_execution_time else None,
293 last_snapshot_name=schedule.last_snapshot_name,
294 last_execution_status=schedule.last_execution_status,
295 + scheduled_hour=schedule.scheduled_hour,
296 + scheduled_minute=schedule.scheduled_minute,
297 + interval_days=schedule.interval_days,
298 + day_of_week=schedule.day_of_week,
299 + timezone=schedule.timezone,
300 created_at=schedule.created_at.isoformat(),
301 updated_at=schedule.updated_at.isoformat(),
302 )
@@ -646,6 +724,11 @@ async def create_snapshot_schedule(
724 include_global_state=request.include_global_state if request.include_global_state is not None else False,
725 skip_write_indices=request.skip_write_indices if request.skip_write_indices is not None else True,
726 retention_days=request.retention_days,
727 + scheduled_hour=request.scheduled_hour,
728 + scheduled_minute=request.scheduled_minute,
729 + interval_days=request.interval_days if request.interval_days is not None else 1,
730 + day_of_week=request.day_of_week,
731 + timezone=request.timezone or "UTC",
732 )
733
734 session.add(schedule)
@@ -801,6 +884,19 @@ async def update_snapshot_schedule(
884 schedule.skip_write_indices = request.skip_write_indices
885 if request.retention_days is not None:
886 schedule.retention_days = request.retention_days
887 + # Allow explicit NULL on scheduled_hour/minute so users can clear
888 + # the time-of-day gate via the API (Pydantic tracks set fields).
889 + fields_set = request.__fields_set__
890 + if "scheduled_hour" in fields_set:
891 + schedule.scheduled_hour = request.scheduled_hour
892 + if "scheduled_minute" in fields_set:
893 + schedule.scheduled_minute = request.scheduled_minute
894 + if request.interval_days is not None:
895 + schedule.interval_days = request.interval_days
896 + if "day_of_week" in fields_set:
897 + schedule.day_of_week = request.day_of_week
898 + if request.timezone is not None:
899 + schedule.timezone = request.timezone
900
901 schedule.updated_at = datetime.utcnow()
902
@@ -1012,6 +1108,30 @@ async def execute_snapshot_schedule(
1108 logger.info(f"Executing snapshot schedule: {schedule.name} (ID: {schedule.id})")
1109
1110 try:
1111 + # Time-of-day / day-interval gate. Cheap check that runs before any
1112 + # indexer API calls — defers the snapshot until it falls inside the
1113 + # configured window. Legacy schedules with no scheduling fields set
1114 + # behave exactly as before (always due, every poll).
1115 + due, defer_reason = _is_schedule_due(schedule)
1116 + if not due:
1117 + logger.info(f"Schedule {schedule.name}: {defer_reason}")
1118 + schedule.last_execution_status = defer_reason
1119 + schedule.updated_at = datetime.utcnow()
1120 + try:
1121 + await session.commit()
1122 + except Exception:
1123 + await session.rollback()
1124 + return ScheduledSnapshotExecutionResponse(
1125 + schedule_id=schedule.id,
1126 + schedule_name=schedule.name,
1127 + snapshot_name=None,
1128 + indices_snapshotted=[],
1129 + skipped_write_indices=[],
1130 + already_snapshotted_indices=[],
1131 + success=True,
1132 + message=defer_reason,
1133 + )
1134 +
1135 # Determine which indices need to be snapshotted
1136 indices_to_snapshot, skipped_write_indices, already_snapshotted = await get_indices_needing_snapshot(schedule)
1137
backend/app/schedulers/scheduler.py
+5 -1
@@ -155,7 +155,11 @@ async def initialize_job_metadata():
155 },
156 {
157 "job_id": "invoke_snapshot_schedules",
158 - "time_interval": 60,
158 + # Poll every 15 min so per-schedule scheduled_hour/minute gating
159 + # in execute_snapshot_schedule has tight enough resolution.
160 + # See SCHEDULE_MATCH_TOLERANCE_MINUTES in
161 + # app/connectors/wazuh_indexer/services/snapshot_and_restore.py.
162 + "time_interval": 15,
163 "function": invoke_snapshot_schedules,
164 "description": "Invokes Index snapshot schedules execution.",
165 },
docs/user/ui/indices-snapshots.md
+57
@@ -72,6 +72,63 @@ Restoring brings historical data back so you can:
72
73 If you regularly offload older logs, scheduled snapshots help keep disk usage stable.
74
75 +### Schedule window (time-of-day + day interval)
76 +
77 +By default a snapshot schedule is evaluated every 15 minutes — and if there are new indices to snapshot, it fires immediately. That can mean snapshot operations hit the Wazuh Indexer during business hours, when the cluster is busiest.
78 +
79 +Each schedule has four optional fields that pin execution to a maintenance window:
80 +
81 +| Field | Type | Purpose |
82 +|---|---|---|
83 +| **Day of Week** | `Monday … Sunday` (or empty) | Restrict execution to a single weekday. Empty = any day. |
84 +| **Scheduled Hour** | `0–23` (or empty) | Hour of day the schedule is allowed to run. Empty = any hour (legacy behavior — runs every poll). |
85 +| **Scheduled Minute** | `0–59` (or empty) | Minute of hour. Pairs with Scheduled Hour to form a **15-minute window** starting at this minute. Empty = any minute in the chosen hour. Disabled until Scheduled Hour is set. |
86 +| **Interval (Days)** | `≥ 1`, default `1` | Minimum days between executions. `1` = at most once per day. `14` paired with **Day of Week** = "every other Sunday". |
87 +| **Timezone** | IANA name, default `UTC` | Used to evaluate Day of Week / Scheduled Hour / Scheduled Minute. Examples: `UTC`, `America/Chicago`, `Europe/London`. DST is handled automatically. |
88 +
89 +When the current time is outside the window, the schedule's **Last Execution** column shows a `DEFERRED` tag and **no Wazuh Indexer API calls are made** — there's effectively zero cluster load on deferred polls. The next poll inside the window picks up where it left off, and the existing "no new indices = SKIPPED" deduplication still applies.
90 +
91 +#### Example — daily at 02:00 UTC
92 +
93 +| Field | Value |
94 +|---|---|
95 +| Scheduled Hour | `2` |
96 +| Scheduled Minute | `0` |
97 +| Interval (Days) | `1` |
98 +| Timezone | `UTC` |
99 +
100 +Result: between 02:00 and 02:14 UTC each day, the schedule fires (assuming new indices exist). All other polls are deferred.
101 +
102 +#### Example — weekly on Sunday at 01:00 US Central
103 +
104 +| Field | Value |
105 +|---|---|
106 +| Day of Week | `Sunday` |
107 +| Scheduled Hour | `1` |
108 +| Scheduled Minute | `0` |
109 +| Interval (Days) | `1` |
110 +| Timezone | `America/Chicago` |
111 +
112 +> `America/Chicago` correctly handles US Central time year-round — CST in winter (UTC-6) and CDT in summer (UTC-5).
113 +
114 +Result: only fires on Sundays between 01:00 and 01:14 Central. All other days/times defer.
115 +
116 +#### Example — every other Sunday at 01:00 US Central
117 +
118 +| Field | Value |
119 +|---|---|
120 +| Day of Week | `Sunday` |
121 +| Scheduled Hour | `1` |
122 +| Scheduled Minute | `0` |
123 +| Interval (Days) | `14` |
124 +| Timezone | `America/Chicago` |
125 +
126 +The `Interval (Days) = 14` blocks runs for 13 days after the last successful execution, so the next eligible Sunday after a fired run lands exactly two weeks later.
127 +
128 +#### Backward compatibility
129 +
130 +Schedules created before this feature was introduced — and any new schedule where Scheduled Hour is left empty — keep their original behavior: they run on every 15-minute poll and rely solely on the "new indices needed" check.
131 +
132 ---
133
134 ## Common gotchas
frontend/src/components/snapshots/SnapshotScheduleForm.vue
+109 -3
@@ -52,6 +52,77 @@
52 </template>
53 </n-form-item>
54
55 + <n-divider title-placement="left">Schedule Window</n-divider>
56 +
57 + <n-form-item label="Day of Week" path="day_of_week">
58 + <n-select
59 + v-model:value="formData.day_of_week"
60 + :options="weekdayOptions"
61 + placeholder="Any day"
62 + clearable
63 + />
64 + <template #feedback>
65 + Restrict execution to a single day of the week. Leave empty to allow any day.
66 + Combines with Interval (Days) for patterns like "every other Sunday".
67 + </template>
68 + </n-form-item>
69 +
70 + <n-form-item label="Scheduled Hour" path="scheduled_hour">
71 + <n-input-number
72 + v-model:value="formData.scheduled_hour"
73 + :min="0"
74 + :max="23"
75 + placeholder="Leave empty for any hour"
76 + clearable
77 + style="width: 100%"
78 + />
79 + <template #feedback>
80 + Hour of day (0-23) when this schedule is allowed to run. Leave empty to allow any hour
81 + (legacy behavior — runs every poll).
82 + </template>
83 + </n-form-item>
84 +
85 + <n-form-item label="Scheduled Minute" path="scheduled_minute">
86 + <n-input-number
87 + v-model:value="formData.scheduled_minute"
88 + :min="0"
89 + :max="59"
90 + placeholder="Leave empty for any minute"
91 + clearable
92 + style="width: 100%"
93 + :disabled="formData.scheduled_hour == null"
94 + />
95 + <template #feedback>
96 + Minute of hour. The schedule runs within a 15-minute tolerance window starting at
97 + this minute. Requires Scheduled Hour to be set.
98 + </template>
99 + </n-form-item>
100 +
101 + <n-form-item label="Interval (Days)" path="interval_days">
102 + <n-input-number
103 + v-model:value="formData.interval_days"
104 + :min="1"
105 + :max="365"
106 + style="width: 100%"
107 + />
108 + <template #feedback>
109 + Minimum number of days between executions. Default 1 = at most once per day.
110 + </template>
111 + </n-form-item>
112 +
113 + <n-form-item label="Timezone" path="timezone">
114 + <n-select
115 + v-model:value="formData.timezone"
116 + :options="timezoneOptions"
117 + filterable
118 + tag
119 + placeholder="Select or type IANA timezone"
120 + />
121 + <template #feedback>
122 + IANA timezone used to evaluate Scheduled Hour/Minute (e.g., UTC, America/New_York).
123 + </template>
124 + </n-form-item>
125 +
126 <div class="mt-4 flex justify-end gap-2">
127 <n-button @click="$emit('cancel')">Cancel</n-button>
128 <n-button type="primary" :loading @click="handleSubmit">
@@ -65,7 +136,7 @@
136 // TODO-FE: refactor
137 import type { FormInst, FormRules, SelectOption } from "naive-ui"
138 import type { SnapshotRepository, SnapshotScheduleCreate, SnapshotScheduleResponse } from "@/types/snapshots.d"
68 -import { NButton, NForm, NFormItem, NInput, NInputNumber, NSelect, NSwitch, useMessage } from "naive-ui"
139 +import { NButton, NDivider, NForm, NFormItem, NInput, NInputNumber, NSelect, NSwitch, useMessage } from "naive-ui"
140 import { computed, onBeforeMount, ref, watch } from "vue"
141 import Api from "@/api"
142
@@ -94,7 +165,12 @@ const formData = ref<SnapshotScheduleCreate>({
165 snapshot_prefix: "scheduled",
166 include_global_state: false,
167 skip_write_indices: true,
97 - retention_days: null
168 + retention_days: null,
169 + scheduled_hour: null,
170 + scheduled_minute: null,
171 + interval_days: 1,
172 + day_of_week: null,
173 + timezone: "UTC"
174 })
175
176 const repositoryOptions = computed<SelectOption[]>(() =>
@@ -104,6 +180,31 @@ const repositoryOptions = computed<SelectOption[]>(() =>
180 }))
181 )
182
183 +// Python convention: Monday=0 ... Sunday=6 (matches datetime.weekday()).
184 +const weekdayOptions: SelectOption[] = [
185 + { label: "Monday", value: 0 },
186 + { label: "Tuesday", value: 1 },
187 + { label: "Wednesday", value: 2 },
188 + { label: "Thursday", value: 3 },
189 + { label: "Friday", value: 4 },
190 + { label: "Saturday", value: 5 },
191 + { label: "Sunday", value: 6 }
192 +]
193 +
194 +const timezoneOptions: SelectOption[] = [
195 + { label: "UTC", value: "UTC" },
196 + { label: "America/New_York", value: "America/New_York" },
197 + { label: "America/Chicago", value: "America/Chicago" },
198 + { label: "America/Denver", value: "America/Denver" },
199 + { label: "America/Los_Angeles", value: "America/Los_Angeles" },
200 + { label: "Europe/London", value: "Europe/London" },
201 + { label: "Europe/Berlin", value: "Europe/Berlin" },
202 + { label: "Europe/Paris", value: "Europe/Paris" },
203 + { label: "Asia/Tokyo", value: "Asia/Tokyo" },
204 + { label: "Asia/Singapore", value: "Asia/Singapore" },
205 + { label: "Australia/Sydney", value: "Australia/Sydney" }
206 +]
207 +
208 const rules: FormRules = {
209 name: {
210 required: true,
@@ -134,7 +235,12 @@ watch(
235 snapshot_prefix: newSchedule.snapshot_prefix,
236 include_global_state: newSchedule.include_global_state,
237 skip_write_indices: newSchedule.skip_write_indices,
137 - retention_days: newSchedule.retention_days
238 + retention_days: newSchedule.retention_days,
239 + scheduled_hour: newSchedule.scheduled_hour ?? null,
240 + scheduled_minute: newSchedule.scheduled_minute ?? null,
241 + interval_days: newSchedule.interval_days ?? 1,
242 + day_of_week: newSchedule.day_of_week ?? null,
243 + timezone: newSchedule.timezone ?? "UTC"
244 }
245 }
246 },
frontend/src/components/snapshots/SnapshotSchedules.vue
+44 -6
@@ -103,25 +103,63 @@ const columns: DataTableColumns<SnapshotScheduleResponse> = [
103 return row.retention_days ? `${row.retention_days} days` : "Forever"
104 }
105 },
106 + {
107 + title: "Schedule",
108 + key: "scheduled_hour",
109 + render(row) {
110 + const hour = row.scheduled_hour
111 + const minute = row.scheduled_minute
112 + const interval = row.interval_days ?? 1
113 + const tz = row.timezone || "UTC"
114 + const dow = row.day_of_week
115 + // Python convention: Monday=0 ... Sunday=6.
116 + const weekdayNames = ["Mondays", "Tuesdays", "Wednesdays", "Thursdays", "Fridays", "Saturdays", "Sundays"]
117 +
118 + let cadence: string
119 + if (dow != null) {
120 + const dayLabel = weekdayNames[dow] ?? `Day ${dow}`
121 + cadence = interval > 1 ? `${dayLabel} every ${interval} days` : dayLabel
122 + } else {
123 + cadence = interval === 1 ? "Daily" : `Every ${interval} days`
124 + }
125 +
126 + if (hour == null) {
127 + return h("div", { class: "flex flex-col" }, [
128 + h("span", {}, cadence),
129 + h("span", { class: "text-xs text-gray-500" }, "Any hour")
130 + ])
131 + }
132 +
133 + const hourStr = String(hour).padStart(2, "0")
134 + const minStr = String(minute ?? 0).padStart(2, "0")
135 +
136 + return h("div", { class: "flex flex-col" }, [
137 + h("span", {}, `${cadence} at ${hourStr}:${minStr}`),
138 + h("span", { class: "text-xs text-gray-500" }, tz)
139 + ])
140 + }
141 + },
142 {
143 title: "Last Execution",
144 key: "last_execution_time",
145 render(row) {
146 if (!row.last_execution_time) return "-"
147 + const status = row.last_execution_status || ""
148 + const tagType = status.startsWith("SUCCESS")
149 + ? "success"
150 + : status.startsWith("SKIPPED") || status.startsWith("DEFERRED")
151 + ? "warning"
152 + : "error"
153 return h("div", { class: "flex flex-col" }, [
154 h("span", {}, new Date(row.last_execution_time).toLocaleString()),
155 h(
156 NTag,
157 {
116 - type: row.last_execution_status?.startsWith("SUCCESS")
117 - ? "success"
118 - : row.last_execution_status?.startsWith("SKIPPED")
119 - ? "warning"
120 - : "error",
158 + type: tagType,
159 size: "small",
160 class: "mt-1"
161 },
124 - () => row.last_execution_status?.split(":")[0] || "Unknown"
162 + () => status.split(":")[0] || "Unknown"
163 )
164 ])
165 }
frontend/src/types/snapshots.d.ts
+15
@@ -129,6 +129,11 @@ export interface SnapshotScheduleCreate {
129 include_global_state?: boolean
130 skip_write_indices?: boolean
131 retention_days?: number | null
132 + scheduled_hour?: number | null
133 + scheduled_minute?: number | null
134 + interval_days?: number
135 + day_of_week?: number | null
136 + timezone?: string
137 }
138
139 export interface SnapshotScheduleUpdate {
@@ -140,6 +145,11 @@ export interface SnapshotScheduleUpdate {
145 include_global_state?: boolean
146 skip_write_indices?: boolean
147 retention_days?: number | null
148 + scheduled_hour?: number | null
149 + scheduled_minute?: number | null
150 + interval_days?: number
151 + day_of_week?: number | null
152 + timezone?: string
153 }
154
155 export interface SnapshotScheduleResponse {
@@ -155,6 +165,11 @@ export interface SnapshotScheduleResponse {
165 last_execution_time?: string | null
166 last_snapshot_name?: string | null
167 last_execution_status?: string | null
168 + scheduled_hour?: number | null
169 + scheduled_minute?: number | null
170 + interval_days: number
171 + day_of_week?: number | null
172 + timezone: string
173 created_at: string
174 updated_at: string
175 }