diff --git a/README.md b/README.md index 204c340..082e72d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ QueryCanary is a lightweight tool for monitoring your production data using SQL. ## What It Does - ✅ Run SQL checks against your production database -- ✅ Schedule checks using flexible cron expressions +- ✅ Schedule checks with simple choices, local times, or custom cron expressions - ✅ Get alerts via email or Slack when values drift or break - ✅ See historical trends and chart results over time - ✅ Build custom reports that group metrics from multiple data sources diff --git a/assets/js/app.js b/assets/js/app.js index 80e3833..e6c0e26 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -28,6 +28,7 @@ import SQLEditor from "./hooks/sql_editor"; import CheckChart from "./hooks/check_chart"; import HomeChart from "./hooks/home_chart"; import ReportMetricDrag from "./hooks/report_metric_drag"; +import ScheduleTimezone from "./hooks/schedule_timezone"; import "prismjs"; import "prismjs/components/prism-sql"; @@ -37,6 +38,7 @@ Hooks.SQLEditor = SQLEditor; Hooks.CheckChart = CheckChart; Hooks.HomeChart = HomeChart; Hooks.ReportMetricDrag = ReportMetricDrag; +Hooks.ScheduleTimezone = ScheduleTimezone; const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const liveSocket = new LiveSocket("/live", Socket, { diff --git a/assets/js/hooks/schedule_timezone.js b/assets/js/hooks/schedule_timezone.js new file mode 100644 index 0000000..12cb5aa --- /dev/null +++ b/assets/js/hooks/schedule_timezone.js @@ -0,0 +1,12 @@ +export default { + mounted() { + if (this.el.dataset.autoTimezone !== "true") return; + + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const select = this.el.querySelector("[data-timezone-select]"); + + if (timezone && select && [...select.options].some(option => option.value === timezone)) { + this.pushEvent("detect_timezone", {timezone}); + } + } +}; diff --git a/lib/query_canary/checks/check.ex b/lib/query_canary/checks/check.ex index 110451c..b4eea87 100644 --- a/lib/query_canary/checks/check.ex +++ b/lib/query_canary/checks/check.ex @@ -9,6 +9,7 @@ defmodule QueryCanary.Checks.Check do schema "checks" do field :name, :string field :schedule, :string + field :timezone, :string, default: "Etc/UTC" field :enabled, :boolean, default: false field :query, :string field :expectation, :map @@ -30,6 +31,7 @@ defmodule QueryCanary.Checks.Check do |> cast(attrs, [ :name, :schedule, + :timezone, :enabled, :query, :server_id, @@ -40,12 +42,14 @@ defmodule QueryCanary.Checks.Check do |> validate_required([ :name, :schedule, + :timezone, :enabled, :query, :server_id, :notification_preferences ]) |> validate_cron_expression(:schedule) + |> validate_timezone() |> foreign_key_constraint(:server_id) |> validate_server_id_unchanged() |> validate_server_access(user_scope) @@ -96,6 +100,15 @@ defmodule QueryCanary.Checks.Check do end) end + defp validate_timezone(changeset) do + validate_change(changeset, :timezone, fn _, timezone -> + case DateTime.shift_zone(DateTime.utc_now(), timezone) do + {:ok, _} -> [] + {:error, _} -> [timezone: "is not a valid time zone"] + end + end) + end + defp validate_server_access(changeset, user_scope) do server_id = get_field(changeset, :server_id) diff --git a/lib/query_canary/checks/schedule.ex b/lib/query_canary/checks/schedule.ex new file mode 100644 index 0000000..cf1e756 --- /dev/null +++ b/lib/query_canary/checks/schedule.ex @@ -0,0 +1,227 @@ +defmodule QueryCanary.Checks.Schedule do + @moduledoc "Converts common check schedules to cron and evaluates them in a check's time zone." + + @intervals %{ + "minute" => "* * * * *", + "5_minutes" => "*/5 * * * *", + "15_minutes" => "*/15 * * * *", + "30_minutes" => "*/30 * * * *", + "hourly" => "0 * * * *" + } + + @weekdays ~w(Sunday Monday Tuesday Wednesday Thursday Friday Saturday) + + def options do + [ + {"Every minute", "minute"}, + {"Every 5 minutes", "5_minutes"}, + {"Every 15 minutes", "15_minutes"}, + {"Every 30 minutes", "30_minutes"}, + {"Every hour", "hourly"}, + {"Every day", "daily"}, + {"Weekdays", "weekdays"}, + {"Every week", "weekly"}, + {"Every month", "monthly"}, + {"Custom cron", "custom"} + ] + end + + def weekdays, do: Enum.with_index(@weekdays) |> Enum.map(fn {name, day} -> {name, day} end) + def monthdays, do: Enum.map(1..28, &{Integer.to_string(&1), &1}) + + def from_cron(nil), do: from_cron("0 8 * * *") + + def from_cron(cron) do + default = %{"kind" => "custom", "time" => "08:00", "weekday" => "1", "monthday" => "1"} + + cond do + kind = + Enum.find_value(@intervals, fn {kind, expression} -> if expression == cron, do: kind end) -> + %{default | "kind" => kind} + + match = Regex.run(~r/^(\d{1,2}) (\d{1,2}) \* \* \*$/, cron) -> + time_ui(default, "daily", match) + + match = Regex.run(~r/^(\d{1,2}) (\d{1,2}) \* \* 1-5$/, cron) -> + time_ui(default, "weekdays", match) + + match = Regex.run(~r/^(\d{1,2}) (\d{1,2}) \* \* ([0-6])$/, cron) -> + [_, minute, hour, weekday] = match + time_ui(default, "weekly", [nil, minute, hour]) |> Map.put("weekday", weekday) + + match = Regex.run(~r/^(\d{1,2}) (\d{1,2}) (\d{1,2}) \* \*$/, cron) -> + [_, minute, hour, monthday] = match + + if String.to_integer(monthday) in 1..28 do + time_ui(default, "monthly", [nil, minute, hour]) |> Map.put("monthday", monthday) + else + default + end + + true -> + default + end + end + + defp time_ui(default, kind, [_, minute, hour]) do + case Time.new(String.to_integer(hour), String.to_integer(minute), 0) do + {:ok, _} -> %{default | "kind" => kind, "time" => pad(hour) <> ":" <> pad(minute)} + _ -> default + end + end + + defp pad(value), + do: value |> String.to_integer() |> Integer.to_string() |> String.pad_leading(2, "0") + + def to_cron(%{"kind" => kind} = ui) do + cond do + Map.has_key?(@intervals, kind) -> + {:ok, Map.fetch!(@intervals, kind)} + + kind in ~w(daily weekdays weekly monthly) -> + with {:ok, time} <- Time.from_iso8601(Map.get(ui, "time", "") <> ":00") do + minute = time.minute + hour = time.hour + + case kind do + "daily" -> {:ok, "#{minute} #{hour} * * *"} + "weekdays" -> {:ok, "#{minute} #{hour} * * 1-5"} + "weekly" -> numbered_cron(ui["weekday"], 0..6, "#{minute} #{hour} * * ") + "monthly" -> numbered_cron(ui["monthday"], 1..28, "#{minute} #{hour} ", " * *") + end + else + _ -> {:error, "Choose a valid time"} + end + + true -> + {:error, "Choose a schedule"} + end + end + + defp numbered_cron(value, range, prefix, suffix \\ "") do + case Integer.parse(to_string(value)) do + {number, ""} -> + if number in range, + do: {:ok, prefix <> Integer.to_string(number) <> suffix}, + else: {:error, "Choose a valid day"} + + _ -> + {:error, "Choose a valid day"} + end + end + + def description(cron, timezone) do + ui = from_cron(cron) + kind = ui["kind"] + + case kind do + "minute" -> + "Every minute" + + "5_minutes" -> + "Every 5 minutes" + + "15_minutes" -> + "Every 15 minutes" + + "30_minutes" -> + "Every 30 minutes" + + "hourly" -> + "Every hour" + + "daily" -> + "Every day at #{display_time(ui)} (#{timezone})" + + "weekdays" -> + "Weekdays at #{display_time(ui)} (#{timezone})" + + "weekly" -> + "Every #{Enum.at(@weekdays, String.to_integer(ui["weekday"]))} at #{display_time(ui)} (#{timezone})" + + "monthly" -> + "Day #{ui["monthday"]} of each month at #{display_time(ui)} (#{timezone})" + + _ -> + "Custom cron: #{cron} (#{timezone})" + end + end + + defp display_time(ui) do + {:ok, time} = Time.from_iso8601(ui["time"] <> ":00") + Calendar.strftime(time, "%-I:%M %p") + end + + def matches?(cron, timezone, %DateTime{} = utc_now) do + with {:ok, expression} <- Crontab.CronExpression.Parser.parse(cron), + {:ok, local} <- DateTime.shift_zone(utc_now, timezone) do + Crontab.DateChecker.matches_date?(expression, local) and + not repeated_calendar_time?(cron, local) + else + _ -> false + end + end + + def next_runs(cron, timezone, now \\ DateTime.utc_now(), count \\ 3) do + with {:ok, expression} <- Crontab.CronExpression.Parser.parse(cron), + {:ok, local_now} <- DateTime.shift_zone(now, timezone) do + # Start before the current local hour so a repeated DST hour is included. + start_at = local_now |> DateTime.to_naive() |> NaiveDateTime.add(-7200, :second) + boundary = local_now |> DateTime.to_naive() |> NaiveDateTime.add(10_800, :second) + + expression + |> Crontab.Scheduler.get_next_run_dates(start_at) + |> candidates_through(boundary, count, now, timezone, cron) + |> Enum.sort(&(DateTime.compare(&1, &2) != :gt)) + |> Enum.filter(&(DateTime.compare(&1, now) == :gt)) + |> Enum.take(count) + else + _ -> [] + end + rescue + _ -> [] + end + + defp candidates_through(stream, boundary, count, now, timezone, cron) do + {runs, _} = + Enum.reduce_while(stream, {[], 0}, fn candidate, {acc, future_count} -> + resolved = resolve_local_time(candidate, timezone, cron) + future_count = future_count + Enum.count(resolved, &(DateTime.compare(&1, now) == :gt)) + acc = Enum.reverse(resolved, acc) + + if NaiveDateTime.compare(candidate, boundary) == :gt and future_count >= count do + {:halt, {acc, future_count}} + else + {:cont, {acc, future_count}} + end + end) + + Enum.reverse(runs) + end + + defp resolve_local_time(naive, timezone, cron) do + case DateTime.from_naive(naive, timezone) do + {:ok, datetime} -> + [datetime] + + {:ambiguous, first, second} -> + if calendar_schedule?(cron), do: [first], else: [first, second] + + {:gap, _, _} -> + [] + + _ -> + [] + end + end + + defp repeated_calendar_time?(cron, local) do + calendar_schedule?(cron) and + case DateTime.from_naive(DateTime.to_naive(local), local.time_zone) do + {:ambiguous, _first, second} -> DateTime.compare(local, second) == :eq + _ -> false + end + end + + defp calendar_schedule?(cron), do: from_cron(cron)["kind"] in ~w(daily weekdays weekly monthly) +end diff --git a/lib/query_canary/jobs/check_scheduler.ex b/lib/query_canary/jobs/check_scheduler.ex index d989194..f33f9df 100644 --- a/lib/query_canary/jobs/check_scheduler.ex +++ b/lib/query_canary/jobs/check_scheduler.ex @@ -4,6 +4,7 @@ defmodule QueryCanary.Jobs.CheckScheduler do require Logger alias QueryCanary.Jobs.CheckRunner + alias QueryCanary.Checks.Schedule @impl Oban.Worker def perform(_) do @@ -13,18 +14,12 @@ defmodule QueryCanary.Jobs.CheckScheduler do enabled_checks = QueryCanary.Checks.list_enabled_checks_for_everyone() Enum.each(enabled_checks, fn check -> - case Crontab.CronExpression.Parser.parse(check.schedule) do - {:ok, expr} -> - if Crontab.DateChecker.matches_date?(expr, now) do - Logger.info("Scheduling check #{check.name} at #{now}") + if Schedule.matches?(check.schedule, check.timezone || "Etc/UTC", now) do + Logger.info("Scheduling check #{check.name} at #{now}") - %{"id" => check.id} - |> CheckRunner.new() - |> Oban.insert() - end - - {:error, reason} -> - Logger.warning("Invalid cron: #{check.schedule} (#{reason})") + %{"id" => check.id} + |> CheckRunner.new() + |> Oban.insert() end end) diff --git a/lib/query_canary_web/components/schedule_picker.ex b/lib/query_canary_web/components/schedule_picker.ex new file mode 100644 index 0000000..8bad365 --- /dev/null +++ b/lib/query_canary_web/components/schedule_picker.ex @@ -0,0 +1,143 @@ +defmodule QueryCanaryWeb.Components.SchedulePicker do + use Phoenix.Component + + alias QueryCanary.Checks.Schedule + import QueryCanaryWeb.CoreComponents, only: [input: 1] + + attr :form, :any, required: true + attr :ui, :map, required: true + attr :next_runs, :list, required: true + attr :auto_timezone, :boolean, default: false + + def schedule_picker(assigns) do + assigns = + assigns + |> assign(:options, Schedule.options()) + |> assign(:weekdays, Schedule.weekdays()) + |> assign(:monthdays, Schedule.monthdays()) + |> assign(:timezones, Tzdata.zone_list() |> Enum.sort()) + + ~H""" +
+
+

When should this check run?

+

+ Choose a common schedule or use cron for more control. +

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +

Choose 1–28 so the check runs every month.

+
+
+ +
+ <.input field={@form[:schedule]} type="text" label="Cron expression" placeholder="0 8 * * *" /> +

Five fields: minute hour day month weekday.

+
+ <.input :if={@ui["kind"] != "custom"} field={@form[:schedule]} type="hidden" /> +

+ {elem(error, 0)} +

+ +
+ + +

+ {elem(error, 0)} +

+
+ +
+

+ {Schedule.description( + @form[:schedule].value || "0 8 * * *", + @form[:timezone].value || "Etc/UTC" + )} +

+
+

Next three runs

+
    +
  1. + {Calendar.strftime(run, "%a, %b %-d, %Y at %-I:%M %p %Z")} +
  2. +
+

+ Enter a valid schedule and time zone to see upcoming runs. +

+
+
+

+ At a daylight saving change, a skipped clock time does not run. A repeated daily, weekday, weekly, or monthly time runs once. +

+
+ """ + end +end diff --git a/lib/query_canary_web/live/check_live/form.ex b/lib/query_canary_web/live/check_live/form.ex index fea4cd5..0117634 100644 --- a/lib/query_canary_web/live/check_live/form.ex +++ b/lib/query_canary_web/live/check_live/form.ex @@ -4,7 +4,9 @@ defmodule QueryCanaryWeb.CheckLive.Form do alias QueryCanary.Checks alias QueryCanary.Checks.Check alias QueryCanaryWeb.NotificationComponents + alias QueryCanaryWeb.ScheduleForm import QueryCanaryWeb.NotificationComponents, only: [notification_fields: 1] + import QueryCanaryWeb.Components.SchedulePicker, only: [schedule_picker: 1] on_mount {QueryCanaryWeb.CheckAuth, :edit} @@ -21,7 +23,12 @@ defmodule QueryCanaryWeb.CheckLive.Form do <.input field={@form[:name]} type="text" label="Name" /> <.input field={@form[:enabled]} type="checkbox" label="Enabled" /> <.input field={@form[:public]} type="checkbox" label="Publicly Viewable?" /> - <.input field={@form[:schedule]} type="text" label="Cron Schedule" /> + <.schedule_picker + form={@form} + ui={@schedule_ui} + next_runs={@next_runs} + auto_timezone={@auto_timezone} + /> <.live_component module={QueryCanaryWeb.Components.SQLEditor} id="check-sql-editor" @@ -63,6 +70,9 @@ defmodule QueryCanaryWeb.CheckLive.Form do socket |> assign(:page_title, "Edit Check") |> assign(:check, check) + |> assign(:schedule_ui, ScheduleForm.initial_ui(check)) + |> assign(:next_runs, QueryCanary.Checks.Schedule.next_runs(check.schedule, check.timezone)) + |> assign(:auto_timezone, false) |> assign( :notification_settings, NotificationComponents.settings( @@ -75,24 +85,76 @@ defmodule QueryCanaryWeb.CheckLive.Form do end defp apply_action(socket, :new, _params) do - check = %Check{user_id: socket.assigns.current_scope.user.id} + check = %Check{user_id: socket.assigns.current_scope.user.id, schedule: "0 8 * * *"} socket |> assign(:page_title, "New Check") |> assign(:check, check) + |> assign(:schedule_ui, ScheduleForm.initial_ui(check)) + |> assign(:next_runs, QueryCanary.Checks.Schedule.next_runs(check.schedule, check.timezone)) + |> assign(:auto_timezone, true) |> assign(:form, to_form(Checks.change_check(socket.assigns.current_scope, check))) end @impl true - def handle_event("validate", %{"check" => check_params}, socket) do + def handle_event("validate", %{"check" => check_params} = params, socket) do + {check_params, ui, error} = + ScheduleForm.prepare( + check_params, + params["schedule_ui"], + socket.assigns.schedule_ui, + socket.assigns.form[:schedule].value + ) + changeset = Checks.change_check(socket.assigns.current_scope, socket.assigns.check, check_params) + |> ScheduleForm.add_schedule_error(error) + + {:noreply, + assign(socket, + form: to_form(changeset, action: :validate), + schedule_ui: ui, + next_runs: ScheduleForm.next_runs(changeset), + auto_timezone: false + )} + end + + def handle_event("save", %{"check" => check_params} = params, socket) do + {check_params, ui, error} = + ScheduleForm.prepare( + check_params, + params["schedule_ui"], + socket.assigns.schedule_ui, + socket.assigns.form[:schedule].value + ) - {:noreply, assign(socket, form: to_form(changeset, action: :validate))} + socket = assign(socket, schedule_ui: ui, auto_timezone: false) + + if error do + changeset = + Checks.change_check(socket.assigns.current_scope, socket.assigns.check, check_params) + |> ScheduleForm.add_schedule_error(error) + + {:noreply, assign(socket, form: to_form(changeset), next_runs: [])} + else + save_check(socket, socket.assigns.live_action, check_params) + end end - def handle_event("save", %{"check" => check_params}, socket) do - save_check(socket, socket.assigns.live_action, check_params) + def handle_event("detect_timezone", %{"timezone" => timezone}, socket) do + if socket.assigns.auto_timezone and + match?({:ok, _}, DateTime.shift_zone(DateTime.utc_now(), timezone)) do + changeset = Ecto.Changeset.put_change(socket.assigns.form.source, :timezone, timezone) + + {:noreply, + assign(socket, + form: to_form(changeset), + next_runs: ScheduleForm.next_runs(changeset), + auto_timezone: false + )} + else + {:noreply, socket} + end end defp save_check(socket, :edit, check_params) do @@ -106,7 +168,8 @@ defmodule QueryCanaryWeb.CheckLive.Form do )} {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, form: to_form(changeset))} + {:noreply, + assign(socket, form: to_form(changeset), next_runs: ScheduleForm.next_runs(changeset))} end end @@ -121,7 +184,8 @@ defmodule QueryCanaryWeb.CheckLive.Form do )} {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, form: to_form(changeset))} + {:noreply, + assign(socket, form: to_form(changeset), next_runs: ScheduleForm.next_runs(changeset))} end end diff --git a/lib/query_canary_web/live/check_live/show.ex b/lib/query_canary_web/live/check_live/show.ex index e77a07d..7ebe488 100644 --- a/lib/query_canary_web/live/check_live/show.ex +++ b/lib/query_canary_web/live/check_live/show.ex @@ -3,6 +3,7 @@ defmodule QueryCanaryWeb.CheckLive.Show do alias QueryCanary.Checks alias QueryCanary.Checks.{ChartData, CheckResult} + alias QueryCanary.Checks.Schedule import QueryCanaryWeb.Components.CheckAnalysis @@ -18,7 +19,10 @@ defmodule QueryCanaryWeb.CheckLive.Show do {@check.name} <:subtitle> - Last run: {@last_run} • Next run: {@next_run} • Schedule: {@check.schedule} + Last run: {@last_run} • Next run: {@next_run} • {Schedule.description( + @check.schedule, + @check.timezone || "Etc/UTC" + )} <:actions> <.button @@ -119,15 +123,21 @@ defmodule QueryCanaryWeb.CheckLive.Show do else: hd(recent_results) last_run = - if Enum.empty?(recent_results), - do: "No previous run", - else: - hd(recent_results) |> Map.get(:inserted_at) |> Calendar.strftime("%Y-%m-%d %H:%M:%S") + if Enum.empty?(recent_results) do + "No previous run" + else + recent_results + |> hd() + |> Map.get(:inserted_at) + |> DateTime.shift_zone!(check.timezone || "Etc/UTC") + |> Calendar.strftime("%Y-%m-%d %H:%M:%S %Z") + end next_run = - Crontab.CronExpression.Parser.parse!(check.schedule) - |> Crontab.Scheduler.get_next_run_date!() - |> Calendar.strftime("%Y-%m-%d %H:%M:%S") + case Schedule.next_runs(check.schedule, check.timezone || "Etc/UTC", DateTime.utc_now(), 1) do + [run] -> Calendar.strftime(run, "%Y-%m-%d %H:%M:%S %Z") + [] -> "Unavailable" + end {:ok, socket diff --git a/lib/query_canary_web/live/quickstart/check_live.ex b/lib/query_canary_web/live/quickstart/check_live.ex index e88c8df..0062d5b 100644 --- a/lib/query_canary_web/live/quickstart/check_live.ex +++ b/lib/query_canary_web/live/quickstart/check_live.ex @@ -1,13 +1,13 @@ defmodule QueryCanaryWeb.Quickstart.CheckLive do use QueryCanaryWeb, :live_view - import Crontab.CronExpression - alias QueryCanary.Servers alias QueryCanary.Checks alias QueryCanary.Checks.Check + alias QueryCanaryWeb.ScheduleForm alias QueryCanaryWeb.NotificationComponents import QueryCanaryWeb.NotificationComponents, only: [notification_fields: 1] + import QueryCanaryWeb.Components.SchedulePicker, only: [schedule_picker: 1] def render(assigns) do ~H""" @@ -42,25 +42,12 @@ defmodule QueryCanaryWeb.Quickstart.CheckLive do /> -
-
- - <.input - field={@form[:schedule]} - type="text" - value={@form[:schedule].value || "0 8 * * *"} - /> -
- -
- - -
-
+ <.schedule_picker + form={@form} + ui={@schedule_ui} + next_runs={@next_runs} + auto_timezone={@auto_timezone} + /> <.notification_fields form={@form} settings={@notification_settings} @@ -91,9 +78,12 @@ defmodule QueryCanaryWeb.Quickstart.CheckLive do def mount(%{"server_id" => server_id}, _session, socket) do server = Servers.get_server!(socket.assigns.current_scope, server_id) - check = %Check{user_id: socket.assigns.current_scope.user.id, server_id: server.id} - # CronExpression.Parser.parse!("0 8 * * *") + check = %Check{ + user_id: socket.assigns.current_scope.user.id, + server_id: server.id, + schedule: "0 8 * * *" + } {:ok, socket @@ -104,10 +94,9 @@ defmodule QueryCanaryWeb.Quickstart.CheckLive do NotificationComponents.settings(socket.assigns.current_scope, server, connected?(socket)) ) |> assign(:result, nil) - |> assign( - :next_schedule, - Enum.take(Crontab.Scheduler.get_next_run_dates(~e[0 8 * * *]), 3) - ) + |> assign(:schedule_ui, ScheduleForm.initial_ui(check)) + |> assign(:next_runs, Checks.Schedule.next_runs(check.schedule, check.timezone)) + |> assign(:auto_timezone, true) |> assign( :form, to_form(Checks.change_check(socket.assigns.current_scope, check)) @@ -120,6 +109,9 @@ defmodule QueryCanaryWeb.Quickstart.CheckLive do {:noreply, socket |> assign(:check, check) + |> assign(:schedule_ui, ScheduleForm.initial_ui(check)) + |> assign(:next_runs, Checks.Schedule.next_runs(check.schedule, check.timezone)) + |> assign(:auto_timezone, false) |> assign( :form, to_form(Checks.change_check(socket.assigns.current_scope, check)) @@ -137,21 +129,72 @@ defmodule QueryCanaryWeb.Quickstart.CheckLive do {:noreply, socket} end - def handle_event("validate", %{"check" => check_params}, socket) do + def handle_event("validate", %{"check" => check_params} = params, socket) do + {check_params, ui, error} = + ScheduleForm.prepare( + check_params, + params["schedule_ui"], + socket.assigns.schedule_ui, + socket.assigns.form[:schedule].value + ) + changeset = Checks.change_check( socket.assigns.current_scope, socket.assigns.check, check_params ) + |> ScheduleForm.add_schedule_error(error) {:noreply, socket - |> maybe_put_next_schedule(changeset) - |> assign(form: to_form(changeset, action: :validate))} + |> assign( + form: to_form(changeset, action: :validate), + schedule_ui: ui, + next_runs: ScheduleForm.next_runs(changeset), + auto_timezone: false + )} end - def handle_event("save", %{"check" => check_params}, socket) do + def handle_event("save", %{"check" => check_params} = params, socket) do + {check_params, ui, error} = + ScheduleForm.prepare( + check_params, + params["schedule_ui"], + socket.assigns.schedule_ui, + socket.assigns.form[:schedule].value + ) + + socket = assign(socket, schedule_ui: ui, auto_timezone: false) + + if error do + changeset = + Checks.change_check(socket.assigns.current_scope, socket.assigns.check, check_params) + |> ScheduleForm.add_schedule_error(error) + + {:noreply, assign(socket, form: to_form(changeset), next_runs: [])} + else + save_check(socket, check_params) + end + end + + def handle_event("detect_timezone", %{"timezone" => timezone}, socket) do + if socket.assigns.auto_timezone and + match?({:ok, _}, DateTime.shift_zone(DateTime.utc_now(), timezone)) do + changeset = Ecto.Changeset.put_change(socket.assigns.form.source, :timezone, timezone) + + {:noreply, + assign(socket, + form: to_form(changeset), + next_runs: ScheduleForm.next_runs(changeset), + auto_timezone: false + )} + else + {:noreply, socket} + end + end + + defp save_check(socket, check_params) do current_scope = socket.assigns.current_scope check = @@ -198,24 +241,8 @@ defmodule QueryCanaryWeb.Quickstart.CheckLive do end {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, form: to_form(changeset))} - end - end - - def maybe_put_next_schedule(socket, changeset) do - schedule = Ecto.Changeset.get_change(changeset, :schedule) - - try do - next_schedule = - case Crontab.CronExpression.Parser.parse(schedule || "") do - {:ok, exp} -> Enum.take(Crontab.Scheduler.get_next_run_dates(exp), 3) - _ -> socket.assigns.next_schedule - end - - socket |> assign(:next_schedule, next_schedule) - catch - _e -> - socket.assigns.next_schedule + {:noreply, + assign(socket, form: to_form(changeset), next_runs: ScheduleForm.next_runs(changeset))} end end diff --git a/lib/query_canary_web/schedule_form.ex b/lib/query_canary_web/schedule_form.ex new file mode 100644 index 0000000..bdc0827 --- /dev/null +++ b/lib/query_canary_web/schedule_form.ex @@ -0,0 +1,43 @@ +defmodule QueryCanaryWeb.ScheduleForm do + @moduledoc false + + alias QueryCanary.Checks.Schedule + + def initial_ui(check), do: Schedule.from_cron(check.schedule) + + def prepare(check_params, ui_params, current_ui, current_cron) do + ui_params = ui_params || %{} + ui = Map.merge(current_ui, Map.take(ui_params, ~w(kind time weekday monthday))) + raw_cron = Map.get(check_params, "schedule", current_cron) + + cond do + ui != current_ui and ui["kind"] != "custom" -> + case Schedule.to_cron(ui) do + {:ok, cron} -> {Map.put(check_params, "schedule", cron), ui, nil} + {:error, error} -> {Map.put(check_params, "schedule", ""), ui, error} + end + + ui != current_ui and ui["kind"] == "custom" -> + {Map.put(check_params, "schedule", raw_cron), ui, nil} + + raw_cron != current_cron -> + next_ui = if ui["kind"] == "custom", do: ui, else: Schedule.from_cron(raw_cron) + {check_params, next_ui, nil} + + true -> + {check_params, ui, nil} + end + end + + def add_schedule_error(changeset, nil), do: changeset + + def add_schedule_error(changeset, error) do + Ecto.Changeset.add_error(changeset, :schedule, error) + end + + def next_runs(changeset) do + cron = Ecto.Changeset.get_field(changeset, :schedule) + timezone = Ecto.Changeset.get_field(changeset, :timezone) + Schedule.next_runs(cron || "", timezone || "Etc/UTC") + end +end diff --git a/priv/docs/overview.md b/priv/docs/overview.md index e75887d..4a7cd0e 100644 --- a/priv/docs/overview.md +++ b/priv/docs/overview.md @@ -56,7 +56,7 @@ WHERE created_at >= CURRENT_DATE - INTERVAL '1 day'; ``` ### 3. Set a schedule -QueryCanary supports a full crontab syntax, so you can customize check intervals to pretty much anything you can imagine. +Choose a frequency such as every 15 minutes, daily, weekdays, weekly, or monthly. For calendar schedules, choose a time and time zone. The form previews the next three runs before you save. Custom five-field cron expressions are available under **Custom cron**. Existing cron schedules remain editable. ### 4. Get alerted When QueryCanary detects a change in your data, it'll send you or your team an email alert. @@ -230,4 +230,4 @@ WHERE stock_quantity < 5; - [Quickstart](https://querycanary.com/quickstart) -Need help? support@querycanary.com or join our [Discord](https://discord.gg/Y6UMkgWXue) \ No newline at end of file +Need help? support@querycanary.com or join our [Discord](https://discord.gg/Y6UMkgWXue) diff --git a/priv/repo/migrations/20260925000200_add_check_timezone.exs b/priv/repo/migrations/20260925000200_add_check_timezone.exs new file mode 100644 index 0000000..f1c07fa --- /dev/null +++ b/priv/repo/migrations/20260925000200_add_check_timezone.exs @@ -0,0 +1,9 @@ +defmodule QueryCanary.Repo.Migrations.AddCheckTimezone do + use Ecto.Migration + + def change do + alter table(:checks) do + add :timezone, :string, null: false, default: "Etc/UTC" + end + end +end diff --git a/test/query_canary/checks/schedule_test.exs b/test/query_canary/checks/schedule_test.exs new file mode 100644 index 0000000..a13f22f --- /dev/null +++ b/test/query_canary/checks/schedule_test.exs @@ -0,0 +1,66 @@ +defmodule QueryCanary.Checks.ScheduleTest do + use ExUnit.Case, async: true + + alias QueryCanary.Checks.Schedule + + test "common schedules round trip through cron" do + for {kind, expected} <- [ + {"minute", "* * * * *"}, + {"5_minutes", "*/5 * * * *"}, + {"hourly", "0 * * * *"}, + {"daily", "30 9 * * *"}, + {"weekdays", "30 9 * * 1-5"}, + {"weekly", "30 9 * * 2"}, + {"monthly", "30 9 1 * *"} + ] do + ui = %{"kind" => kind, "time" => "09:30", "weekday" => "2", "monthday" => "1"} + assert {:ok, ^expected} = Schedule.to_cron(ui) + assert Schedule.from_cron(expected)["kind"] == kind + end + + assert Schedule.from_cron("7 13 * * 2,4")["kind"] == "custom" + + assert {:error, _} = + Schedule.to_cron(%{"kind" => "monthly", "time" => "09:30", "monthday" => "31"}) + end + + test "daily local time follows summer and winter offsets" do + cron = "0 9 * * *" + zone = "America/New_York" + + assert Schedule.matches?(cron, zone, ~U[2026-01-15 14:00:00Z]) + refute Schedule.matches?(cron, zone, ~U[2026-01-15 13:00:00Z]) + assert Schedule.matches?(cron, zone, ~U[2026-07-15 13:00:00Z]) + refute Schedule.matches?(cron, zone, ~U[2026-07-15 14:00:00Z]) + end + + test "a skipped local time does not appear in the preview" do + [first, second, third] = + Schedule.next_runs("30 2 * * *", "America/New_York", ~U[2026-03-08 00:00:00Z]) + + assert DateTime.shift_zone!(first, "Etc/UTC") == ~U[2026-03-09 06:30:00Z] + assert DateTime.shift_zone!(second, "Etc/UTC") == ~U[2026-03-10 06:30:00Z] + assert DateTime.shift_zone!(third, "Etc/UTC") == ~U[2026-03-11 06:30:00Z] + end + + test "a repeated daily time runs once, while intervals keep running" do + zone = "America/New_York" + first = ~U[2026-11-01 05:30:00Z] + second = ~U[2026-11-01 06:30:00Z] + + assert Schedule.matches?("30 1 * * *", zone, first) + refute Schedule.matches?("30 1 * * *", zone, second) + assert Schedule.matches?("*/30 * * * *", zone, first) + assert Schedule.matches?("*/30 * * * *", zone, second) + + [next, later | _] = Schedule.next_runs("30 1 * * *", zone, ~U[2026-11-01 04:00:00Z]) + assert DateTime.shift_zone!(next, "Etc/UTC") == first + assert DateTime.shift_zone!(later, "Etc/UTC") == ~U[2026-11-02 06:30:00Z] + + [interval_first, interval_second | _] = + Schedule.next_runs("* * * * *", zone, ~U[2026-11-01 05:29:00Z]) + + assert DateTime.shift_zone!(interval_first, "Etc/UTC") == ~U[2026-11-01 05:30:00Z] + assert DateTime.shift_zone!(interval_second, "Etc/UTC") == ~U[2026-11-01 05:31:00Z] + end +end diff --git a/test/query_canary_web/live/check_live_test.exs b/test/query_canary_web/live/check_live_test.exs index d1e8db9..62746e6 100644 --- a/test/query_canary_web/live/check_live_test.exs +++ b/test/query_canary_web/live/check_live_test.exs @@ -33,10 +33,9 @@ defmodule QueryCanaryWeb.CheckLiveTest do @update_attrs %{ name: "Updated Check", - enabled: false, - schedule: "0 0 * * *" + enabled: false } - @invalid_attrs %{name: "", schedule: ""} + @invalid_attrs %{name: ""} setup :register_and_log_in_user @@ -184,6 +183,47 @@ defmodule QueryCanaryWeb.CheckLiveTest do end end + describe "Scheduling" do + setup [:create_check] + + test "sets a weekday time and timezone without typing cron", %{ + conn: conn, + check: check, + scope: scope + } do + {:ok, view, _} = live(conn, ~p"/checks/#{check}/edit") + + view + |> form("#check-form", schedule_ui: %{kind: "weekdays"}) + |> render_change() + + html = + view + |> form("#check-form", + schedule_ui: %{time: "09:30"}, + check: %{timezone: "America/New_York"} + ) + |> render_change() + + assert html =~ "Weekdays at 9:30 AM (America/New_York)" + assert html =~ "Next three runs" + + view |> form("#check-form") |> render_submit() + saved = Checks.get_check!(scope, check.id) + assert saved.schedule == "30 9 * * 1-5" + assert saved.timezone == "America/New_York" + end + + test "keeps an unusual cron expression editable", %{conn: conn, scope: scope, check: check} do + {:ok, check} = Checks.update_check(scope, check, %{schedule: "7 13 * * 2,4"}) + {:ok, view, _} = live(conn, ~p"/checks/#{check}/edit") + assert has_element?(view, "#schedule-kind option[selected][value='custom']") + + view |> form("#check-form", check: %{schedule: "5 9 * * 1-5"}) |> render_submit() + assert Checks.get_check!(scope, check.id).schedule == "5 9 * * 1-5" + end + end + describe "Permissions" do test "public viewers cannot rerun a check even with a forged event", %{conn: conn} do check = check_fixture(user_scope_fixture(), %{public: true}) diff --git a/test/query_canary_web/live/quickstart_schedule_test.exs b/test/query_canary_web/live/quickstart_schedule_test.exs new file mode 100644 index 0000000..a90a23e --- /dev/null +++ b/test/query_canary_web/live/quickstart_schedule_test.exs @@ -0,0 +1,26 @@ +defmodule QueryCanaryWeb.QuickstartScheduleTest do + use QueryCanaryWeb.ConnCase + + import Phoenix.LiveViewTest + import QueryCanary.ServersFixtures + + setup :register_and_log_in_user + + test "quickstart previews a local daily schedule", %{conn: conn, scope: scope} do + server = server_fixture(scope) + {:ok, view, _} = live(conn, ~p"/quickstart/check?server_id=#{server.id}") + + assert has_element?(view, "#schedule-kind option[selected][value='daily']") + + html = + view + |> form("#check-form", + schedule_ui: %{time: "14:15"}, + check: %{timezone: "Europe/London"} + ) + |> render_change() + + assert html =~ "Every day at 2:15 PM (Europe/London)" + assert html =~ "Next three runs" + end +end