neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
Log in to mark this repository.
One realtime control per feed URL, not per kind
b6d9a98 · Gabriel Morell · 2026-08-25 21:44
Message
{commit_body(@commit)}
Files changed
modified
?
+0
−0
Binary file — not rendered.
modified
?
+0
−0
Binary file — not rendered.
modified
apps/room_gtfs/lib/room_gtfs/gtfs_worker.ex
+68
−36
| 342 | 342 | # A source polling every three minutes can happily use data up to three |
| 343 | 343 | # minutes old, and several sources sharing one URL -- which is the whole point |
| 344 | 344 | # of the regional feed -- then fetch it once between them instead of once each. |
| 345 | − defp cached_get(state, kind, url) do | |
| 346 | − FeedCache.get(url, max(rt_period_ms(state, kind) - :timer.seconds(5), :timer.seconds(1))) | |
| 347 | − end | |
| 348 | − | |
| 349 | 345 | defp record_health(state, kind, url, result, took) do |
| 350 | 346 | RoomGtfs.FeedHealth.record(state.id, state.inst && state.inst.name, kind, url, result, took) |
| 351 | 347 | end |
| 766 | 762 | # Gated here rather than by rescheduling the Periodic, which cannot be changed |
| 767 | 763 | # once started and would have to be torn down and rebuilt whenever the config |
| 768 | 764 | # is refreshed -- every four seconds, as it happens. |
| 769 | − defp due?(state, kind) do | |
| 770 | − case state |> Map.get(:rt_polled_at, %{}) |> Map.get(kind) do | |
| 765 | + defp due?(state, url, period) do | |
| 766 | + case state |> Map.get(:rt_polled_at, %{}) |> Map.get(url) do | |
| 771 | 767 | nil -> true |
| 772 | − at -> System.monotonic_time(:millisecond) - at >= rt_period_ms(state, kind) | |
| 768 | + at -> System.monotonic_time(:millisecond) - at >= period | |
| 773 | 769 | end |
| 774 | 770 | end |
| 775 | 771 | |
| 776 | − defp mark_polled(state, kind) do | |
| 777 | − polled = state |> Map.get(:rt_polled_at, %{}) |> Map.put(kind, System.monotonic_time(:millisecond)) | |
| 772 | + defp mark_polled(state, url) do | |
| 773 | + polled = | |
| 774 | + state |> Map.get(:rt_polled_at, %{}) |> Map.put(url, System.monotonic_time(:millisecond)) | |
| 775 | + | |
| 778 | 776 | Map.put(state, :rt_polled_at, polled) |
| 779 | 777 | end |
| 780 | 778 |
| 797 | 795 | defp do_update_realtime(state) do |
| 798 | 796 | state = |
| 799 | 797 | if state.inst.enabled do |
| 800 | − Enum.reduce([:sa, :tu, :vp], state, &fetch_kind/2) | |
| 798 | + state.inst.config | |
| 799 | + |> rt_groups() | |
| 800 | + |> Enum.reduce(state, &fetch_group/2) | |
| 801 | 801 | else |
| 802 | 802 | bcast(state.id, :disabled) |
| 803 | 803 | state |
| 806 | 806 | {:noreply, state} |
| 807 | 807 | end |
| 808 | 808 | |
| 809 | − # Where each kind comes from. A kind with no URL of its own falls back to the | |
| 810 | − # combined feed, so an agency publishing everything in one message -- the MTA | |
| 811 | − # subway feeds are 67 trip updates, 45 vehicle positions and an alert in a | |
| 812 | − # single URL -- names it once instead of three times. Specific beats general, | |
| 813 | − # so a combined feed plus a dedicated alerts feed works too. | |
| 814 | − defp rt_url(config, :sa), do: Map.get(config, :url_rt_sa) || Map.get(config, :url_rt_shared) | |
| 815 | − defp rt_url(config, :tu), do: Map.get(config, :url_rt_tu) || Map.get(config, :url_rt_shared) | |
| 816 | − defp rt_url(config, :vp), do: Map.get(config, :url_rt_vp) || Map.get(config, :url_rt_shared) | |
| 809 | + @doc """ | |
| 810 | + The kinds this source reads, grouped by the URL they come from. | |
| 817 | 811 | |
| 818 | − defp fetch_kind(kind, state) do | |
| 819 | − url = rt_url(state.inst.config, kind) | |
| 812 | + A URL is the unit of scheduling, not a kind. Where three kinds have three | |
| 813 | + feeds that is the same thing; where one feed carries all three -- the MTA's | |
| 814 | + subway feeds -- it very much is not. Polling those kinds independently meant | |
| 815 | + fetching the same URL on the fastest of their three clocks and then handing | |
| 816 | + the slower kinds whatever the cache still held, so vehicle positions could be | |
| 817 | + minutes stale while trip updates from the very same message were seconds old. | |
| 818 | + Nothing was saved by it: the request had already been made. | |
| 820 | 819 | |
| 821 | − cond do | |
| 822 | − is_nil(url) -> state | |
| 823 | − not due?(state, kind) -> state | |
| 824 | − true -> do_fetch_kind(kind, url, mark_polled(state, kind)) | |
| 820 | + Grouped, one fetch updates everything that came in it. | |
| 821 | + """ | |
| 822 | + def rt_groups(config) do | |
| 823 | + [:tu, :vp, :sa] | |
| 824 | + |> Enum.map(fn kind -> {kind, rt_url(config, kind)} end) | |
| 825 | + |> Enum.reject(fn {_kind, url} -> url in [nil, ""] end) | |
| 826 | + |> Enum.reduce([], fn {kind, url}, acc -> | |
| 827 | + case List.keyfind(acc, url, 0) do | |
| 828 | + nil -> acc ++ [{url, [kind]}] | |
| 829 | + {^url, kinds} -> List.keyreplace(acc, url, 0, {url, kinds ++ [kind]}) | |
| 830 | + end | |
| 831 | + end) | |
| 832 | + end | |
| 833 | + | |
| 834 | + defp fetch_group({url, kinds}, state) do | |
| 835 | + # The fastest kind in the group wins: the URL is fetched that often, and | |
| 836 | + # every kind in it is that fresh. Only reachable by hand-editing the config, | |
| 837 | + # since the form offers one control per group. | |
| 838 | + period = kinds |> Enum.map(&rt_period_ms(state, &1)) |> Enum.min() | |
| 839 | + | |
| 840 | + if due?(state, url, period) do | |
| 841 | + state |> mark_polled(url) |> do_fetch_group(url, kinds, period) | |
| 842 | + else | |
| 843 | + state | |
| 825 | 844 | end |
| 826 | 845 | end |
| 827 | 846 | |
| 828 | − defp do_fetch_kind(kind, url, state) do | |
| 847 | + defp do_fetch_group(state, url, kinds, period) do | |
| 829 | 848 | started = System.monotonic_time(:millisecond) |
| 830 | − result = cached_get(state, kind, url) | |
| 849 | + result = FeedCache.get(url, max(period - :timer.seconds(5), :timer.seconds(1))) | |
| 831 | 850 | took = System.monotonic_time(:millisecond) - started |
| 832 | 851 | |
| 833 | 852 | case result do |
| 834 | 853 | {:ok, feed} when is_struct(feed, TransitRealtime.FeedMessage) -> |
| 835 | − # Narrowed before it is recorded, so health reports what this kind | |
| 836 | − # actually got. Recording the raw feed would have every kind of a | |
| 837 | − # combined source claim the same entity count -- exactly the reading | |
| 838 | − # that means "the same URL is in all three fields by mistake", and | |
| 839 | − # would say it about a correct configuration. | |
| 840 | − feed = feed |> for_this_agency(state) |> slice(entity_field(kind)) | |
| 841 | − record_health(state, kind, url, {:ok, feed}, took) | |
| 842 | − store_feed(state, kind, feed) | |
| 854 | + feed = for_this_agency(feed, state) | |
| 855 | + | |
| 856 | + Enum.reduce(kinds, state, fn kind, acc -> | |
| 857 | + # Narrowed before it is recorded, so health reports what this kind | |
| 858 | + # actually got. Recording the whole message would have every kind of a | |
| 859 | + # combined feed claim the same entity count -- exactly the reading | |
| 860 | + # that means "the same URL is in all three fields by mistake", and | |
| 861 | + # would say it about a correct configuration. | |
| 862 | + sliced = slice(feed, entity_field(kind)) | |
| 863 | + record_health(acc, kind, url, {:ok, sliced}, took) | |
| 864 | + store_feed(acc, kind, sliced) | |
| 865 | + end) | |
| 843 | 866 | |
| 844 | 867 | {:error, error} -> |
| 845 | − record_health(state, kind, url, result, took) | |
| 868 | + Enum.each(kinds, &record_health(state, &1, url, result, took)) | |
| 846 | 869 | |
| 847 | 870 | Logger.info( |
| 848 | − "failed to fetch gtfs-rt url[#{kind}] for '#{state.inst.name}', reason: #{inspect(error)}" | |
| 871 | + "failed to fetch gtfs-rt url#{inspect(kinds)} for '#{state.inst.name}', reason: #{inspect(error)}" | |
| 849 | 872 | ) |
| 850 | 873 | |
| 851 | 874 | state |
| 852 | 875 | end |
| 853 | 876 | end |
| 854 | 877 | |
| 878 | + # Where each kind comes from. A kind with no URL of its own falls back to the | |
| 879 | + # combined feed, so an agency publishing everything in one message -- the MTA | |
| 880 | + # subway feeds are 67 trip updates, 45 vehicle positions and an alert in a | |
| 881 | + # single URL -- names it once instead of three times. Specific beats general, | |
| 882 | + # so a combined feed plus a dedicated alerts feed works too. | |
| 883 | + defp rt_url(config, :sa), do: Map.get(config, :url_rt_sa) || Map.get(config, :url_rt_shared) | |
| 884 | + defp rt_url(config, :tu), do: Map.get(config, :url_rt_tu) || Map.get(config, :url_rt_shared) | |
| 885 | + defp rt_url(config, :vp), do: Map.get(config, :url_rt_vp) || Map.get(config, :url_rt_shared) | |
| 886 | + | |
| 855 | 887 | defp entity_field(:sa), do: :alert |
| 856 | 888 | defp entity_field(:tu), do: :trip_update |
| 857 | 889 | defp entity_field(:vp), do: :vehicle |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/form_component.ex
+114
−53
| 89 | 89 | end |
| 90 | 90 | |
| 91 | 91 | @doc """ |
| 92 | − Requests an hour the three intervals add up to, counting each distinct URL | |
| 93 | − once. | |
| 92 | + The kinds a source reads, grouped by the URL they come from -- one control per | |
| 93 | + group, because a URL is what actually gets scheduled. | |
| 94 | 94 | |
| 95 | − A source with one combined feed polls one URL however many kinds read from | |
| 96 | − it, so counting per kind would overstate it threefold -- and the number is | |
| 97 | − only worth showing because some feeds are metered, where an overstatement is | |
| 98 | − as unhelpful as no number at all. | |
| 95 | + Three sliders over one combined feed were a lie: the URL is fetched on the | |
| 96 | + fastest of the three clocks, so setting service alerts slower than trip | |
| 97 | + updates saved no request at all, it just handed the slower kinds a staler copy | |
| 98 | + of a message that had already arrived. One group, one interval, everything in | |
| 99 | + it equally fresh. | |
| 100 | + | |
| 101 | + Delegates, so the form and the poller cannot disagree about what a group is. | |
| 102 | + """ | |
| 103 | + def rt_groups(config) when is_map(config), do: RoomGtfs.Worker.RT.rt_groups(config) | |
| 104 | + def rt_groups(_config), do: [] | |
| 105 | + | |
| 106 | + @doc """ | |
| 107 | + Names a group by what it carries: the kinds when it is some of them, and | |
| 108 | + nothing more specific than "Realtime" when one feed carries all three. | |
| 99 | 109 | """ |
| 100 | − def rt_requests_per_hour(config, idxs) when is_map(config) do | |
| 101 | − for {kind, _label} <- rt_period_kinds(), reduce: %{} do | |
| 102 | − acc -> | |
| 103 | − case rt_url_for(config, kind) do | |
| 104 | − nil -> acc | |
| 105 | − url -> Map.update(acc, url, rt_idx_to_secs(idxs[kind]), &min(&1, rt_idx_to_secs(idxs[kind]))) | |
| 106 | − end | |
| 110 | + def rt_group_label(kinds) do | |
| 111 | + case Enum.sort(kinds) do | |
| 112 | + [:sa, :tu, :vp] -> "Realtime (combined feed)" | |
| 113 | + sorted -> sorted |> Enum.map(&kind_title/1) |> Enum.join(", ") | |
| 107 | 114 | end |
| 108 | − |> Enum.map(fn {_url, secs} -> div(3600, secs) end) | |
| 109 | − |> Enum.sum() | |
| 110 | 115 | end |
| 111 | 116 | |
| 112 | − def rt_requests_per_hour(_config, _idxs), do: 0 | |
| 117 | + defp kind_title(:tu), do: "Trip updates" | |
| 118 | + defp kind_title(:vp), do: "Vehicle positions" | |
| 119 | + defp kind_title(:sa), do: "Service alerts" | |
| 113 | 120 | |
| 114 | − defp rt_url_for(config, kind) do | |
| 115 | − specific = | |
| 116 | − case kind do | |
| 117 | − :tu -> Map.get(config, :url_rt_tu) | |
| 118 | − :vp -> Map.get(config, :url_rt_vp) | |
| 119 | − :sa -> Map.get(config, :url_rt_sa) | |
| 120 | − end | |
| 121 | + @doc "Form key for a group's slider, e.g. `rt_idx_g_sa-tu-vp`." | |
| 122 | + def rt_group_key(kinds), do: "rt_idx_g_" <> (kinds |> Enum.sort() |> Enum.join("-")) | |
| 123 | + | |
| 124 | + @doc """ | |
| 125 | + The index a group's slider sits at. The kinds in a group are written together, | |
| 126 | + so they agree; the fastest wins if a config was edited by hand. | |
| 127 | + """ | |
| 128 | + def rt_group_idx(config, kinds) do | |
| 129 | + kinds | |
| 130 | + |> Enum.map(fn kind -> rt_config_seconds(config, kind) end) | |
| 131 | + |> Enum.reject(&is_nil/1) | |
| 132 | + |> case do | |
| 133 | + [] -> rt_secs_to_idx(nil) | |
| 134 | + seconds -> rt_secs_to_idx(Enum.min(seconds)) | |
| 135 | + end | |
| 136 | + end | |
| 121 | 137 | |
| 122 | − blank(specific) || blank(Map.get(config, :url_rt_shared)) | |
| 138 | + def rt_config_seconds(config, kind) when is_map(config) do | |
| 139 | + Map.get(config, String.to_existing_atom("rt_period_#{kind}")) || | |
| 140 | + Map.get(config, "rt_period_#{kind}") | |
| 123 | 141 | end |
| 124 | 142 | |
| 125 | − defp blank(nil), do: nil | |
| 126 | − defp blank(""), do: nil | |
| 127 | − defp blank(value), do: value | |
| 143 | + def rt_config_seconds(_config, _kind), do: nil | |
| 128 | 144 | |
| 129 | − defp convert_rt_period_params(source_params) do | |
| 130 | − Enum.reduce(rt_period_kinds(), source_params, fn {kind, _label}, params -> | |
| 131 | − case get_in(params, ["config", "rt_idx_#{kind}"]) do | |
| 132 | − nil -> | |
| 133 | − params | |
| 145 | + defp to_seconds(value) when is_integer(value), do: value | |
| 146 | + defp to_seconds(value) when is_binary(value), do: String.to_integer(value) | |
| 134 | 147 | |
| 135 | − idx -> | |
| 136 | − params | |
| 137 | − |> put_in(["config", "rt_period_#{kind}"], rt_idx_to_secs(idx)) | |
| 138 | − |> update_in(["config"], &Map.delete(&1, "rt_idx_#{kind}")) | |
| 139 | − end | |
| 148 | + @doc """ | |
| 149 | + Requests an hour these intervals add up to. | |
| 150 | + | |
| 151 | + One term per group rather than per kind, because a group is one URL: three | |
| 152 | + kinds arriving in one feed cost one request, and counting them separately | |
| 153 | + would treat a combined feed as three times more expensive than it is. The | |
| 154 | + number is only worth showing because some feeds are metered, and an | |
| 155 | + overstatement there is as unhelpful as no number at all. | |
| 156 | + """ | |
| 157 | + def rt_requests_per_hour(config, idxs) when is_map(config) and is_map(idxs) do | |
| 158 | + config | |
| 159 | + |> rt_groups() | |
| 160 | + |> Enum.map(fn {_url, kinds} -> | |
| 161 | + idx = Map.get(idxs, kinds) || rt_group_idx(config, kinds) | |
| 162 | + div(3600, rt_idx_to_secs(idx)) | |
| 163 | + end) | |
| 164 | + |> Enum.sum() | |
| 165 | + end | |
| 166 | + | |
| 167 | + def rt_requests_per_hour(_config, _idxs), do: 0 | |
| 168 | + | |
| 169 | + # One slider writes every kind in its group, so the stored per-kind values | |
| 170 | + # remain the source of truth and a config edited by hand still works. | |
| 171 | + @doc false | |
| 172 | + # Public only so it can be exercised directly: it is the step that turns what | |
| 173 | + # the form submits into what the schema stores, and getting it wrong writes a | |
| 174 | + # poll interval nobody asked for. | |
| 175 | + def convert_rt_period_params(%{"config" => config} = source_params) when is_map(config) do | |
| 176 | + config | |
| 177 | + |> Map.keys() | |
| 178 | + |> Enum.filter(&String.starts_with?(&1, "rt_idx_g_")) | |
| 179 | + |> Enum.reduce(source_params, fn key, params -> | |
| 180 | + secs = rt_idx_to_secs(get_in(params, ["config", key])) | |
| 181 | + | |
| 182 | + kinds = | |
| 183 | + key | |
| 184 | + |> String.replace_prefix("rt_idx_g_", "") | |
| 185 | + |> String.split("-", trim: true) | |
| 186 | + | |
| 187 | + params | |
| 188 | + |> then(fn p -> | |
| 189 | + Enum.reduce(kinds, p, fn kind, acc -> | |
| 190 | + put_in(acc, ["config", "rt_period_#{kind}"], secs) | |
| 191 | + end) | |
| 192 | + end) | |
| 193 | + |> update_in(["config"], &Map.delete(&1, key)) | |
| 140 | 194 | end) |
| 141 | 195 | end |
| 142 | 196 | |
| 197 | + def convert_rt_period_params(source_params), do: source_params | |
| 198 | + | |
| 143 | 199 | # GitHub poll-interval slider: index → label/seconds |
| 144 | 200 | @github_poll_steps [ |
| 145 | 201 | {"15s", 15}, |
| 353 | 409 | |> Enum.map(&{&1.name, &1.id}) |
| 354 | 410 | run_period_idx = seconds_to_idx(source.meta && source.meta.run_period) |
| 355 | 411 | |
| 356 | − rt_idxs = | |
| 357 | − Map.new(rt_period_kinds(), fn {kind, _label} -> | |
| 358 | − secs = | |
| 359 | − case source.config do | |
| 360 | − %RoomSanctum.Configuration.Configs.GTFS{} = cfg -> | |
| 361 | − Map.get(cfg, String.to_existing_atom("rt_period_#{kind}")) | |
| 362 | − | |
| 363 | − _other -> | |
| 364 | − nil | |
| 365 | − end | |
| 366 | − | |
| 367 | − {kind, rt_secs_to_idx(secs)} | |
| 368 | − end) | |
| 412 | + rt_groups = rt_groups(source.config) | |
| 413 | + rt_idxs = Map.new(rt_groups, fn {_url, kinds} -> {kinds, rt_group_idx(source.config, kinds)} end) | |
| 369 | 414 | |
| 370 | 415 | gh_secs = |
| 371 | 416 | case source.config do |
| 399 | 444 | |> assign(:mailbox_sel, mailbox_sel) |
| 400 | 445 | |> assign(:tint_opts, RoomSanctum.Tints.all()) |
| 401 | 446 | |> assign(:run_period_idx, run_period_idx) |
| 447 | + |> assign(:rt_groups, rt_groups) | |
| 402 | 448 | |> assign(:rt_idxs, rt_idxs) |
| 403 | 449 | |> assign(:rt_requests, rt_requests_per_hour(source.config, rt_idxs)) |
| 404 | 450 | |> assign(:github_poll_idx, github_secs_to_idx(gh_secs)) |
| 489 | 535 | # Read back from the converted params rather than the changeset: a slider |
| 490 | 536 | # the user has just dragged is in the params, and its label has to move with |
| 491 | 537 | # it or the control reads as broken. |
| 538 | + rt_groups = rt_groups(config_for_estimate) | |
| 539 | + | |
| 492 | 540 | rt_idxs = |
| 493 | − Map.new(rt_period_kinds(), fn {kind, _label} -> | |
| 494 | − secs = get_in(source_params, ["config", "rt_period_#{kind}"]) | |
| 495 | − {kind, rt_secs_to_idx(secs)} | |
| 541 | + Map.new(rt_groups, fn {_url, kinds} -> | |
| 542 | + # From the converted params, not the changeset: a slider just dragged is | |
| 543 | + # in the params, and its label has to move with it. | |
| 544 | + seconds = | |
| 545 | + kinds | |
| 546 | + |> Enum.map(&get_in(source_params, ["config", "rt_period_#{&1}"])) | |
| 547 | + |> Enum.reject(&is_nil/1) | |
| 548 | + | |
| 549 | + idx = | |
| 550 | + case seconds do | |
| 551 | + [] -> rt_group_idx(config_for_estimate, kinds) | |
| 552 | + values -> values |> Enum.map(&to_seconds/1) |> Enum.min() |> rt_secs_to_idx() | |
| 553 | + end | |
| 554 | + | |
| 555 | + {kinds, idx} | |
| 496 | 556 | end) |
| 497 | 557 | |
| 498 | 558 | {:noreply, |
| 499 | 559 | socket |
| 500 | 560 | |> assign(:run_period_idx, run_period_idx) |
| 561 | + |> assign(:rt_groups, rt_groups) | |
| 501 | 562 | |> assign(:rt_idxs, rt_idxs) |
| 502 | 563 | |> assign(:rt_requests, rt_requests_per_hour(config_for_estimate, rt_idxs)) |
| 503 | 564 | |> assign(:github_poll_idx, gh_idx) |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/form_component.html.heex
+13
−13
| 89 | 89 | </span> |
| 90 | 90 | </label> |
| 91 | 91 | </div> |
| 92 | − <%= for {kind, title} <- RoomSanctumWeb.SourceLive.FormComponent.rt_period_kinds() do %> | |
| 92 | + <%= for {_url, kinds} <- @rt_groups do %> | |
| 93 | 93 | <div class="form-control mt-2"> |
| 94 | 94 | <label class="label"> |
| 95 | 95 | <span class="label-text"> |
| 96 | − <%= title %> interval — | |
| 96 | + <%= RoomSanctumWeb.SourceLive.FormComponent.rt_group_label(kinds) %> interval — | |
| 97 | 97 | <span class="font-bold"> |
| 98 | − <%= RoomSanctumWeb.SourceLive.FormComponent.rt_period_label(@rt_idxs[kind]) %> | |
| 98 | + <%= RoomSanctumWeb.SourceLive.FormComponent.rt_period_label(@rt_idxs[kinds]) %> | |
| 99 | 99 | </span> |
| 100 | 100 | </span> |
| 101 | 101 | </label> |
| 102 | 102 | <input type="range" |
| 103 | − name={"source[config][rt_idx_#{kind}]"} | |
| 104 | − id={"source_config_rt_idx_#{kind}"} | |
| 103 | + name={"source[config][#{RoomSanctumWeb.SourceLive.FormComponent.rt_group_key(kinds)}]"} | |
| 104 | + id={"source_config_#{RoomSanctumWeb.SourceLive.FormComponent.rt_group_key(kinds)}"} | |
| 105 | 105 | min="0" |
| 106 | 106 | max={length(RoomSanctumWeb.SourceLive.FormComponent.rt_period_steps()) - 1} |
| 107 | 107 | step="1" |
| 108 | − value={@rt_idxs[kind]} | |
| 108 | + value={@rt_idxs[kinds]} | |
| 109 | 109 | class="range range-sm range-secondary" /> |
| 110 | 110 | <div class="flex justify-between text-xs px-1 mt-1"> |
| 111 | 111 | <%= for {label, _secs} <- RoomSanctumWeb.SourceLive.FormComponent.rt_period_steps() do %> |
| 114 | 114 | </div> |
| 115 | 115 | </div> |
| 116 | 116 | <% end %> |
| 117 | − <p class="text-xs text-base-content/60 mt-1"> | |
| 118 | − The three kinds go stale at different rates: trip updates in seconds, service | |
| 119 | − alerts a few times a day. Feeds sharing one URL are fetched once between them. | |
| 117 | + <p :if={@rt_groups != []} class="text-xs text-base-content/60 mt-1"> | |
| 118 | + One control per feed URL, since a URL is what gets fetched. Kinds arriving in | |
| 119 | + the same feed are refreshed together — scheduling them apart would fetch | |
| 120 | + that feed on the fastest of their clocks and then hand the others a staler copy | |
| 121 | + of the message that had already arrived. | |
| 120 | 122 | </p> |
| 121 | − <div class="text-xs mt-2 opacity-80"> | |
| 123 | + <div :if={@rt_groups != []} class="text-xs mt-2 opacity-80"> | |
| 122 | 124 | <i class="fa-solid fa-fw fa-gauge-high"></i> |
| 123 | − Roughly | |
| 124 | − <span class="font-bold"><%= @rt_requests %></span> | |
| 125 | − requests an hour, counting each distinct URL once. | |
| 125 | + Roughly <span class="font-bold"><%= @rt_requests %></span> requests an hour. | |
| 126 | 126 | 511.org allows 60 on a default token. |
| 127 | 127 | </div> |
| 128 | 128 | <div class="form-control"> |
Parents: 3b86b77