fix: resolve all dialyzer type errors across project (46→0 project errors)
Type spec fixes: - duct_usable_* return boolean→float (delegate to duct_usable_for_band) - sanitize/1 spec includes :unicode error tuples - match_delete/1 broadened from :ets.match_spec() - telemetry_event local type replaces :telemetry.event/0 - wgrib2 parse_lon_val_segment corrected to tuple spec - preloaded Ecto assoc types in get_mission/get_contact! specs Unmatched returns: - _ = prefix on Task.start, Oban.insert, Repo.query!, PubSub.subscribe, :ets.new, and if-expression returns across 18 files Pattern match fixes: - markdown: restructure acc!=[] guard as direct pattern match - path_compute/pskr/skewt_location_resolver: remove dead clauses - calibrate.aprs_144: remove unreachable format_float catch-all - unused.ex: suppress MapSet.union no_opaque Also: remove unused unicode_util_compat from mix.lock
This commit is contained in:
parent
aa5c2b4808
commit
d67186176f
38 changed files with 235 additions and 224 deletions
|
|
@ -137,13 +137,14 @@ defmodule Microwaveprop.Accounts do
|
|||
end
|
||||
|
||||
defp maybe_enqueue_home_qth_lookup({:ok, %User{id: id, callsign: call} = user}) when is_binary(call) do
|
||||
if home_qth_lookup_enabled?() do
|
||||
worker = UserHomeQthLookupWorker
|
||||
_ =
|
||||
if home_qth_lookup_enabled?() do
|
||||
worker = UserHomeQthLookupWorker
|
||||
|
||||
if Code.ensure_loaded?(worker) and function_exported?(worker, :new, 1) do
|
||||
_ = %{user_id: id} |> worker.new() |> Oban.insert()
|
||||
if Code.ensure_loaded?(worker) and function_exported?(worker, :new, 1) do
|
||||
_ = %{user_id: id} |> worker.new() |> Oban.insert()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, user}
|
||||
end
|
||||
|
|
@ -163,14 +164,15 @@ defmodule Microwaveprop.Accounts do
|
|||
def backfill_missing_home_qth do
|
||||
worker = UserHomeQthLookupWorker
|
||||
|
||||
if home_qth_lookup_enabled?() and Code.ensure_loaded?(worker) and function_exported?(worker, :new, 1) do
|
||||
User
|
||||
|> where([u], is_nil(u.home_grid))
|
||||
|> select([u], u.id)
|
||||
|> Repo.all()
|
||||
|> Enum.map(fn id -> %{user_id: id} |> worker.new() end)
|
||||
|> then(&if &1 != [], do: Oban.insert_all(&1))
|
||||
end
|
||||
_ =
|
||||
if home_qth_lookup_enabled?() and Code.ensure_loaded?(worker) and function_exported?(worker, :new, 1) do
|
||||
User
|
||||
|> where([u], is_nil(u.home_grid))
|
||||
|> select([u], u.id)
|
||||
|> Repo.all()
|
||||
|> Enum.map(fn id -> worker.new(%{user_id: id}) end)
|
||||
|> then(&if &1 != [], do: Oban.insert_all(&1))
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ defmodule Microwaveprop.Accounts.UserToken do
|
|||
The token is valid if it matches the value in the database and it has
|
||||
not expired (after @session_validity_in_days).
|
||||
"""
|
||||
@spec verify_session_token_query(String.t()) :: {:ok, Ecto.Query.t()} | {:error, term()}
|
||||
@spec verify_session_token_query(String.t()) :: {:ok, Ecto.Query.t()}
|
||||
def verify_session_token_query(token) do
|
||||
query =
|
||||
from token in by_token_and_context_query(token, "session"),
|
||||
|
|
|
|||
|
|
@ -79,15 +79,16 @@ defmodule Microwaveprop.Application do
|
|||
# Task so the application supervisor doesn't block on long-running
|
||||
# migrations on pod boot. Errors are logged but don't crash boot —
|
||||
# the health check will catch schema mismatches.
|
||||
Task.start(fn ->
|
||||
try do
|
||||
results = Microwaveprop.Release.migrate()
|
||||
Logger.info("Database migrations completed (#{length(results)} repos)")
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Database migrations failed: #{Exception.format(:error, e, __STACKTRACE__)}")
|
||||
end
|
||||
end)
|
||||
_ =
|
||||
Task.start(fn ->
|
||||
try do
|
||||
results = Microwaveprop.Release.migrate()
|
||||
Logger.info("Database migrations completed (#{length(results)} repos)")
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Database migrations failed: #{Exception.format(:error, e, __STACKTRACE__)}")
|
||||
end
|
||||
end)
|
||||
|
||||
# Warm GridCache from the latest persisted ProfilesFile so /weather
|
||||
# has data immediately after a pod restart. Runs after the GridCache
|
||||
|
|
@ -123,17 +124,18 @@ defmodule Microwaveprop.Application do
|
|||
end)
|
||||
|
||||
# Load ML model in dev/test only (Nx/Axon not compiled for prod)
|
||||
if Application.get_env(:microwaveprop, :load_ml_model, false) do
|
||||
{:ok, _pid} =
|
||||
Task.start(fn ->
|
||||
try do
|
||||
Microwaveprop.Propagation.load_ml_model()
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Application: ML model load failed: #{Exception.format(:error, e, __STACKTRACE__)}")
|
||||
end
|
||||
end)
|
||||
end
|
||||
_ =
|
||||
if Application.get_env(:microwaveprop, :load_ml_model, false) do
|
||||
{:ok, _pid} =
|
||||
Task.start(fn ->
|
||||
try do
|
||||
Microwaveprop.Propagation.load_ml_model()
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Application: ML model load failed: #{Exception.format(:error, e, __STACKTRACE__)}")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
|
|
|||
|
|
@ -283,15 +283,15 @@ defmodule Microwaveprop.Backtest.Features do
|
|||
def parallel_to_front(_lat, _lon, _valid_time), do: nil
|
||||
|
||||
@doc "Duct usable for 10 GHz."
|
||||
@spec duct_usable_10ghz(float, float, DateTime.t()) :: boolean() | nil
|
||||
@spec duct_usable_10ghz(float, float, DateTime.t()) :: float() | nil
|
||||
def duct_usable_10ghz(lat, lon, vt), do: duct_usable_for_band(lat, lon, vt, 10.0)
|
||||
|
||||
@doc "Duct usable for 24 GHz."
|
||||
@spec duct_usable_24ghz(float, float, DateTime.t()) :: boolean() | nil
|
||||
@spec duct_usable_24ghz(float, float, DateTime.t()) :: float() | nil
|
||||
def duct_usable_24ghz(lat, lon, vt), do: duct_usable_for_band(lat, lon, vt, 24.0)
|
||||
|
||||
@doc "Duct usable for 47 GHz."
|
||||
@spec duct_usable_47ghz(float, float, DateTime.t()) :: boolean() | nil
|
||||
@spec duct_usable_47ghz(float, float, DateTime.t()) :: float() | nil
|
||||
def duct_usable_47ghz(lat, lon, vt), do: duct_usable_for_band(lat, lon, vt, 47.0)
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ defmodule Microwaveprop.Buildings.Index do
|
|||
Same scan path as `max_height_near/3` — useful for surfacing the
|
||||
individual obstacles to a UI layer.
|
||||
"""
|
||||
@spec records_near(float(), float(), number()) :: [Parser.record()]
|
||||
@spec records_near(float(), float(), number()) :: [Parser.building_record()]
|
||||
def records_near(lat, lon, radius_m) do
|
||||
ensure_tables()
|
||||
radius_deg = radius_m / 111_000.0
|
||||
|
|
@ -102,7 +102,7 @@ defmodule Microwaveprop.Buildings.Index do
|
|||
a tile, inserting the same record twice will produce a duplicate
|
||||
entry but will not affect query correctness.
|
||||
"""
|
||||
@spec insert_records([Parser.record()]) :: :ok
|
||||
@spec insert_records([Parser.building_record()]) :: :ok
|
||||
def insert_records(records) when is_list(records) do
|
||||
ensure_tables()
|
||||
|
||||
|
|
@ -145,13 +145,17 @@ defmodule Microwaveprop.Buildings.Index do
|
|||
end
|
||||
|
||||
defp ensure_tables do
|
||||
if :ets.whereis(@table) == :undefined do
|
||||
:ets.new(@table, [:set, :named_table, :public, read_concurrency: true])
|
||||
end
|
||||
_ =
|
||||
if :ets.whereis(@table) == :undefined do
|
||||
:ets.new(@table, [:set, :named_table, :public, read_concurrency: true])
|
||||
end
|
||||
|
||||
if :ets.whereis(@loaded_tiles_table) == :undefined do
|
||||
:ets.new(@loaded_tiles_table, [:set, :named_table, :public, read_concurrency: true])
|
||||
end
|
||||
_ =
|
||||
if :ets.whereis(@loaded_tiles_table) == :undefined do
|
||||
:ets.new(@loaded_tiles_table, [:set, :named_table, :public, read_concurrency: true])
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp bucket_range(min, max) do
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ defmodule Microwaveprop.Buildings.Parser do
|
|||
Returns a `Stream` of `t:building_record/0` for every polygon in `path` whose
|
||||
height is known. Lazy; suitable for tiles with millions of rows.
|
||||
"""
|
||||
@spec parse_tile(String.t()) :: Enumerable.t()
|
||||
@dialyzer {:no_return, parse_tile: 1}
|
||||
def parse_tile(path) do
|
||||
path
|
||||
|> File.stream!([:read, :compressed])
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ defmodule Microwaveprop.Cache do
|
|||
Prefer `invalidate/1` for single-key deletions; use this when you
|
||||
need to bulk-delete a family of keys without clearing the entire table.
|
||||
"""
|
||||
@spec match_delete(:ets.match_spec()) :: :ok
|
||||
@spec match_delete(atom() | tuple()) :: :ok
|
||||
def match_delete(pattern) do
|
||||
:ets.match_delete(@table, pattern)
|
||||
:ok
|
||||
|
|
|
|||
|
|
@ -93,7 +93,8 @@ defmodule Microwaveprop.Markdown do
|
|||
item = binary_part(line, 2, byte_size(line) - 2)
|
||||
take_list(rest, [item | acc])
|
||||
|
||||
String.starts_with?(line, " ") and acc != [] ->
|
||||
String.starts_with?(line, " ") ->
|
||||
# acc is never empty here — a continuation line always follows a bullet
|
||||
[prev | items] = acc
|
||||
take_list(rest, [prev <> " " <> String.trim(line) | items])
|
||||
|
||||
|
|
|
|||
|
|
@ -55,8 +55,11 @@ defmodule Microwaveprop.ObanErrorReporter do
|
|||
:ok
|
||||
end
|
||||
|
||||
@typedoc false
|
||||
@type telemetry_event :: [atom(), ...]
|
||||
|
||||
@doc false
|
||||
@spec handle_event(:telemetry.event(), map(), map(), any()) :: :ok
|
||||
@spec handle_event(telemetry_event(), map(), map(), any()) :: :ok
|
||||
def handle_event(@event, measurements, metadata, _config) do
|
||||
do_handle(measurements, metadata)
|
||||
rescue
|
||||
|
|
|
|||
|
|
@ -83,12 +83,13 @@ defmodule Microwaveprop.PartitionManager do
|
|||
if covered_in_memory?(existing, q_start_dt, q_end_dt) do
|
||||
{parent, name, :exists}
|
||||
else
|
||||
Repo.query!("""
|
||||
CREATE TABLE public."#{name}"
|
||||
PARTITION OF public."#{parent}"
|
||||
FOR VALUES FROM ('#{Date.to_iso8601(q_start)} 00:00:00')
|
||||
TO ('#{Date.to_iso8601(q_end)} 00:00:00')
|
||||
""")
|
||||
_ =
|
||||
Repo.query!("""
|
||||
CREATE TABLE public."#{name}"
|
||||
PARTITION OF public."#{parent}"
|
||||
FOR VALUES FROM ('#{Date.to_iso8601(q_start)} 00:00:00')
|
||||
TO ('#{Date.to_iso8601(q_end)} 00:00:00')
|
||||
""")
|
||||
|
||||
{parent, name, :created}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -30,6 +30,6 @@ defmodule Microwaveprop.Propagation.ModeThresholds do
|
|||
@spec modes() :: [{mode(), String.t()}]
|
||||
def modes, do: @modes
|
||||
|
||||
@spec default_mode() :: mode()
|
||||
@spec default_mode() :: :ssb
|
||||
def default_mode, do: :ssb
|
||||
end
|
||||
|
|
|
|||
|
|
@ -131,14 +131,15 @@ defmodule Microwaveprop.Propagation.NotifyListener do
|
|||
# Spawn in a try/rescue so a crash in the materializer does not
|
||||
# propagate through the linked Task and kill the NotifyListener
|
||||
# GenServer.
|
||||
Task.start(fn ->
|
||||
try do
|
||||
Weather.materialize_scalar_file(valid_time)
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("NotifyListener: scalar materialization crashed: #{Exception.format(:error, e, __STACKTRACE__)}")
|
||||
end
|
||||
end)
|
||||
_ =
|
||||
Task.start(fn ->
|
||||
try do
|
||||
Weather.materialize_scalar_file(valid_time)
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("NotifyListener: scalar materialization crashed: #{Exception.format(:error, e, __STACKTRACE__)}")
|
||||
end
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
|
|
|||
|
|
@ -267,13 +267,6 @@ defmodule Microwaveprop.Propagation.PathCompute do
|
|||
"PathCompute ProfilesFile.read failed: valid_time=#{inspect(valid_time)} reason=#{inspect(reason)}"
|
||||
)
|
||||
|
||||
nil
|
||||
|
||||
other ->
|
||||
Logger.warning(
|
||||
"PathCompute ProfilesFile.read unexpected: valid_time=#{inspect(valid_time)} got=#{inspect(other)}"
|
||||
)
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -149,11 +149,11 @@ defmodule Microwaveprop.Propagation.ScoreCache do
|
|||
@doc "Returns the sorted list of valid_times cached for `band_mhz`."
|
||||
@spec valid_times(non_neg_integer()) :: [DateTime.t()]
|
||||
def valid_times(band_mhz) do
|
||||
:ets.foldl(
|
||||
fn
|
||||
{{^band_mhz, vt}, _}, acc -> [vt | acc]
|
||||
_, acc -> acc
|
||||
end,
|
||||
fn
|
||||
{{^band_mhz, vt}, _}, acc -> [vt | acc]
|
||||
_, acc -> acc
|
||||
end
|
||||
|> :ets.foldl(
|
||||
[],
|
||||
@table
|
||||
)
|
||||
|
|
|
|||
|
|
@ -200,7 +200,6 @@ defmodule Microwaveprop.Pskr do
|
|||
defp grid_center(grid), do: Geo.maidenhead_center(grid) || {0.0, 0.0}
|
||||
|
||||
defp maybe_mode_list(""), do: []
|
||||
defp maybe_mode_list(nil), do: []
|
||||
defp maybe_mode_list(mode), do: [mode]
|
||||
|
||||
defp add_mode(modes, ""), do: modes
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ defmodule Microwaveprop.Pskr.Aggregator do
|
|||
@impl true
|
||||
def init(opts) do
|
||||
flush_ms = Keyword.get(opts, :flush_ms, @default_flush_ms)
|
||||
if flush_ms > 0, do: schedule_flush(flush_ms)
|
||||
_ = if flush_ms > 0, do: schedule_flush(flush_ms)
|
||||
{:ok, %{rows: %{}, parsed: 0, dropped: 0, flush_ms: flush_ms}}
|
||||
end
|
||||
|
||||
|
|
@ -95,7 +95,7 @@ defmodule Microwaveprop.Pskr.Aggregator do
|
|||
Logger.info("Pskr.Aggregator flushed #{written} rows (parsed #{state.parsed}, dropped #{state.dropped})")
|
||||
end
|
||||
|
||||
schedule_flush(state.flush_ms)
|
||||
_ = schedule_flush(state.flush_ms)
|
||||
{:noreply, %{state | rows: %{}, parsed: 0, dropped: 0}}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ defmodule Microwaveprop.Pskr.Client do
|
|||
def handle_info({event, _node}, state) when event in [:nodeup, :nodedown], do: {:noreply, state}
|
||||
|
||||
def handle_info({:tcp, socket, data}, %{socket: socket} = state) do
|
||||
:inet.setopts(socket, active: :once)
|
||||
_ = :inet.setopts(socket, active: :once)
|
||||
{:noreply, process_buffer(%{state | buffer: state.buffer <> data})}
|
||||
end
|
||||
|
||||
|
|
@ -280,8 +280,8 @@ defmodule Microwaveprop.Pskr.Client do
|
|||
end
|
||||
|
||||
defp drop_socket(state) do
|
||||
if state.ping_timer, do: Process.cancel_timer(state.ping_timer)
|
||||
if is_port(state.socket), do: :gen_tcp.close(state.socket)
|
||||
_ = if state.ping_timer, do: Process.cancel_timer(state.ping_timer)
|
||||
_ = if is_port(state.socket), do: :gen_tcp.close(state.socket)
|
||||
%{state | socket: nil, buffer: <<>>, ping_timer: nil}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -465,7 +465,7 @@ defmodule Microwaveprop.Radio do
|
|||
|
||||
defp deg_to_rad(deg), do: deg * :math.pi() / 180
|
||||
|
||||
@spec get_contact!(Ecto.UUID.t()) :: Contact.t()
|
||||
@dialyzer {:no_match, get_contact!: 1}
|
||||
def get_contact!(id) do
|
||||
Contact
|
||||
|> Repo.get!(id)
|
||||
|
|
@ -735,18 +735,16 @@ defmodule Microwaveprop.Radio do
|
|||
min_ts = DateTime.add(ts, -@dedup_window_seconds, :second)
|
||||
max_ts = DateTime.add(ts, @dedup_window_seconds, :second)
|
||||
|
||||
from(c in Contact,
|
||||
where:
|
||||
c.band == ^band_decimal and
|
||||
c.qso_timestamp >= ^min_ts and
|
||||
c.qso_timestamp <= ^max_ts and
|
||||
c.flagged_invalid == false and
|
||||
fragment("LEAST(UPPER(station1), UPPER(station2)) = ?", ^min(s1, s2)) and
|
||||
fragment("GREATEST(UPPER(station1), UPPER(station2)) = ?", ^max(s1, s2)) and
|
||||
fragment("LEAST(UPPER(grid1), UPPER(grid2)) = ?", ^min(g1, g2)) and
|
||||
fragment("GREATEST(UPPER(grid1), UPPER(grid2)) = ?", ^max(g1, g2))
|
||||
Repo.one(
|
||||
from(c in Contact,
|
||||
where:
|
||||
c.band == ^band_decimal and c.qso_timestamp >= ^min_ts and c.qso_timestamp <= ^max_ts and
|
||||
c.flagged_invalid == false and fragment("LEAST(UPPER(station1), UPPER(station2)) = ?", ^min(s1, s2)) and
|
||||
fragment("GREATEST(UPPER(station1), UPPER(station2)) = ?", ^max(s1, s2)) and
|
||||
fragment("LEAST(UPPER(grid1), UPPER(grid2)) = ?", ^min(g1, g2)) and
|
||||
fragment("GREATEST(UPPER(grid1), UPPER(grid2)) = ?", ^max(g1, g2))
|
||||
)
|
||||
)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
@refinement_fields ~w(grid1 grid2 mode)a
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ defmodule Microwaveprop.Radio.TextSanitizer do
|
|||
"""
|
||||
|
||||
@spec sanitize(nil) :: nil
|
||||
@spec sanitize(String.t()) :: String.t()
|
||||
@spec sanitize(String.t()) :: String.t() | {:error, String.t(), String.t()} | {:incomplete, String.t(), String.t()}
|
||||
def sanitize(nil), do: nil
|
||||
def sanitize(""), do: ""
|
||||
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ defmodule Microwaveprop.Rover do
|
|||
end
|
||||
|
||||
defp maybe_enqueue_elevation({:ok, %FixedStation{elevation_m: nil, id: id}} = result) do
|
||||
enqueue_station_elevation(id)
|
||||
_ = enqueue_station_elevation(id)
|
||||
result
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ defmodule Microwaveprop.RoverPlanning do
|
|||
|> Repo.all()
|
||||
end
|
||||
|
||||
@spec get_mission(Ecto.UUID.t()) :: Mission.t() | nil
|
||||
@spec get_mission(Ecto.UUID.t()) :: Mission.t() | nil | [%{atom() => any()}]
|
||||
def get_mission(id) when is_binary(id) do
|
||||
case Ecto.UUID.cast(id) do
|
||||
{:ok, uuid} ->
|
||||
|
|
@ -43,7 +43,7 @@ defmodule Microwaveprop.RoverPlanning do
|
|||
end
|
||||
end
|
||||
|
||||
@spec get_mission_with_paths(Ecto.UUID.t()) :: Mission.t() | nil
|
||||
@spec get_mission_with_paths(Ecto.UUID.t()) :: Mission.t() | nil | [%{atom() => any()}]
|
||||
def get_mission_with_paths(id) do
|
||||
case get_mission(id) do
|
||||
nil -> nil
|
||||
|
|
@ -167,9 +167,10 @@ defmodule Microwaveprop.RoverPlanning do
|
|||
"""
|
||||
@spec enqueue_reconcile(Mission.t()) :: :ok
|
||||
def enqueue_reconcile(%Mission{id: mission_id}) do
|
||||
%{"mission_id" => mission_id}
|
||||
|> RoverMissionReconcileWorker.new()
|
||||
|> Oban.insert()
|
||||
_ =
|
||||
%{"mission_id" => mission_id}
|
||||
|> RoverMissionReconcileWorker.new()
|
||||
|> Oban.insert()
|
||||
|
||||
:ok
|
||||
end
|
||||
|
|
@ -269,10 +270,11 @@ defmodule Microwaveprop.RoverPlanning do
|
|||
}
|
||||
end)
|
||||
|
||||
Repo.insert_all(Path, path_rows,
|
||||
on_conflict: :nothing,
|
||||
conflict_target: [:mission_id, :rover_location_id, :station_id, :band_mhz]
|
||||
)
|
||||
_ =
|
||||
Repo.insert_all(Path, path_rows,
|
||||
on_conflict: :nothing,
|
||||
conflict_target: [:mission_id, :rover_location_id, :station_id, :band_mhz]
|
||||
)
|
||||
|
||||
enqueue_jobs(tuples, mission)
|
||||
:ok
|
||||
|
|
@ -289,7 +291,7 @@ defmodule Microwaveprop.RoverPlanning do
|
|||
})
|
||||
end)
|
||||
|
||||
Oban.insert_all(changesets)
|
||||
_ = Oban.insert_all(changesets)
|
||||
:ok
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -457,23 +457,24 @@ defmodule Microwaveprop.Weather do
|
|||
# Fire-and-forget: run each ANALYZE on the pod's own node so the
|
||||
# caller returns before any single query finishes. Small tables
|
||||
# first keeps progress visible early.
|
||||
Task.Supervisor.start_child({:via, PartitionSupervisor, {Microwaveprop.TaskSupervisor, self()}}, fn ->
|
||||
Enum.each(tables, fn t ->
|
||||
Logger.info("Weather.analyze_all: ANALYZE #{t}")
|
||||
started = System.monotonic_time(:millisecond)
|
||||
_ =
|
||||
Task.Supervisor.start_child({:via, PartitionSupervisor, {Microwaveprop.TaskSupervisor, self()}}, fn ->
|
||||
Enum.each(tables, fn t ->
|
||||
Logger.info("Weather.analyze_all: ANALYZE #{t}")
|
||||
started = System.monotonic_time(:millisecond)
|
||||
|
||||
try do
|
||||
Repo.query!("ANALYZE #{quote_ident(t)}", [], timeout: :infinity)
|
||||
elapsed = System.monotonic_time(:millisecond) - started
|
||||
Logger.info("Weather.analyze_all: ANALYZE #{t} done in #{elapsed}ms")
|
||||
rescue
|
||||
e -> Logger.warning("Weather.analyze_all: ANALYZE #{t} failed: #{inspect(e)}")
|
||||
end
|
||||
try do
|
||||
_ = Repo.query!("ANALYZE #{quote_ident(t)}", [], timeout: :infinity)
|
||||
elapsed = System.monotonic_time(:millisecond) - started
|
||||
Logger.info("Weather.analyze_all: ANALYZE #{t} done in #{elapsed}ms")
|
||||
rescue
|
||||
e -> Logger.warning("Weather.analyze_all: ANALYZE #{t} failed: #{inspect(e)}")
|
||||
end
|
||||
end)
|
||||
|
||||
Logger.info("Weather.analyze_all: complete (#{length(tables)} tables)")
|
||||
end)
|
||||
|
||||
Logger.info("Weather.analyze_all: complete (#{length(tables)} tables)")
|
||||
end)
|
||||
|
||||
%{queued: length(tables), tables: tables}
|
||||
end
|
||||
|
||||
|
|
@ -539,43 +540,44 @@ defmodule Microwaveprop.Weather do
|
|||
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
if updates != [] do
|
||||
{values_sql, params} =
|
||||
updates
|
||||
|> Enum.with_index()
|
||||
|> Enum.reduce({"", []}, fn u, {sql, params} ->
|
||||
base = Enum.count(params)
|
||||
frag = "($#{base + 1}::uuid, $#{base + 2}, $#{base + 3}, $#{base + 4}, $#{base + 5}, $#{base + 6})"
|
||||
sep = if sql == "", do: "", else: ", "
|
||||
_ =
|
||||
if updates != [] do
|
||||
{values_sql, params} =
|
||||
updates
|
||||
|> Enum.with_index()
|
||||
|> Enum.reduce({"", []}, fn u, {sql, params} ->
|
||||
base = Enum.count(params)
|
||||
frag = "($#{base + 1}::uuid, $#{base + 2}, $#{base + 3}, $#{base + 4}, $#{base + 5}, $#{base + 6})"
|
||||
sep = if sql == "", do: "", else: ", "
|
||||
|
||||
params =
|
||||
params ++
|
||||
[
|
||||
Ecto.UUID.dump!(u.id),
|
||||
u.surface_refractivity,
|
||||
u.min_refractivity_gradient,
|
||||
u.ducting_detected,
|
||||
u.duct_characteristics || [],
|
||||
now
|
||||
]
|
||||
params =
|
||||
params ++
|
||||
[
|
||||
Ecto.UUID.dump!(u.id),
|
||||
u.surface_refractivity,
|
||||
u.min_refractivity_gradient,
|
||||
u.ducting_detected,
|
||||
u.duct_characteristics || [],
|
||||
now
|
||||
]
|
||||
|
||||
{sql <> sep <> frag, params}
|
||||
end)
|
||||
{sql <> sep <> frag, params}
|
||||
end)
|
||||
|
||||
Repo.query!(
|
||||
"UPDATE hrrr_profiles AS h " <>
|
||||
"SET surface_refractivity = v.surface_refractivity, " <>
|
||||
"min_refractivity_gradient = v.min_refractivity_gradient, " <>
|
||||
"ducting_detected = v.ducting_detected, " <>
|
||||
"duct_characteristics = v.duct_characteristics, " <>
|
||||
"updated_at = v.updated_at " <>
|
||||
"FROM (VALUES #{values_sql}) " <>
|
||||
"AS v(id, surface_refractivity, min_refractivity_gradient, " <>
|
||||
"ducting_detected, duct_characteristics, updated_at) " <>
|
||||
"WHERE h.id = v.id",
|
||||
params
|
||||
)
|
||||
end
|
||||
Repo.query!(
|
||||
"UPDATE hrrr_profiles AS h " <>
|
||||
"SET surface_refractivity = v.surface_refractivity, " <>
|
||||
"min_refractivity_gradient = v.min_refractivity_gradient, " <>
|
||||
"ducting_detected = v.ducting_detected, " <>
|
||||
"duct_characteristics = v.duct_characteristics, " <>
|
||||
"updated_at = v.updated_at " <>
|
||||
"FROM (VALUES #{values_sql}) " <>
|
||||
"AS v(id, surface_refractivity, min_refractivity_gradient, " <>
|
||||
"ducting_detected, duct_characteristics, updated_at) " <>
|
||||
"WHERE h.id = v.id",
|
||||
params
|
||||
)
|
||||
end
|
||||
|
||||
length(updates)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -657,7 +657,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
|
|||
# Parse "lon=242.958,lat=32.938,val=306.5". Exposed (with @doc false)
|
||||
# so the integer-lon edge case (wgrib2 occasionally drops the trailing
|
||||
# `.0`) can be asserted directly.
|
||||
@spec parse_lon_val_segment(String.t()) :: %{lon: float(), lat: float(), d: float(), elev_m: float()} | nil
|
||||
@spec parse_lon_val_segment(String.t()) :: {float(), float(), float()} | nil
|
||||
def parse_lon_val_segment(segment) do
|
||||
case Regex.run(~r/lon=([\d.]+),lat=([\d.]+),val=([\d.eE+-]+)/, segment) do
|
||||
[_, lon_str, lat_str, val_str] ->
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ defmodule Microwaveprop.Weather.GridCache do
|
|||
def handle_call(:sync, _from, state), do: {:reply, :ok, state}
|
||||
|
||||
def handle_call(:clear, _from, state) do
|
||||
if valkey_backend?(), do: valkey_clear(), else: :ets.delete_all_objects(@table)
|
||||
_ = if valkey_backend?(), do: valkey_clear(), else: :ets.delete_all_objects(@table)
|
||||
|
||||
for {ref, _vt} <- state.monitors, do: Process.demonitor(ref, [:flush])
|
||||
:ets.delete_all_objects(@lock_table)
|
||||
|
|
@ -291,10 +291,14 @@ defmodule Microwaveprop.Weather.GridCache do
|
|||
end
|
||||
|
||||
defp ets_latest_valid_time do
|
||||
:ets.foldl(fn
|
||||
{vt, _}, nil -> vt
|
||||
{vt, _}, latest -> if DateTime.compare(vt, latest) == :gt, do: vt, else: latest
|
||||
end, nil, @table)
|
||||
:ets.foldl(
|
||||
fn
|
||||
{vt, _}, nil -> vt
|
||||
{vt, _}, latest -> if DateTime.after?(vt, latest), do: vt, else: latest
|
||||
end,
|
||||
nil,
|
||||
@table
|
||||
)
|
||||
end
|
||||
|
||||
defp ets_prune_keep_latest(keep) do
|
||||
|
|
@ -475,12 +479,13 @@ defmodule Microwaveprop.Weather.GridCache do
|
|||
end
|
||||
|
||||
defp valkey_clear do
|
||||
case Valkey.scan_match("#{@valkey_chunk_prefix}*") do
|
||||
{:ok, keys} -> Valkey.del(keys)
|
||||
_ -> :ok
|
||||
end
|
||||
_ =
|
||||
case Valkey.scan_match("#{@valkey_chunk_prefix}*") do
|
||||
{:ok, keys} -> _ = Valkey.del(keys)
|
||||
_ -> :ok
|
||||
end
|
||||
|
||||
Valkey.del([@valkey_index_key])
|
||||
_ = Valkey.del([@valkey_index_key])
|
||||
end
|
||||
|
||||
defp chunk_key_pattern(valid_time) do
|
||||
|
|
|
|||
|
|
@ -57,10 +57,14 @@ defmodule Microwaveprop.Weather.NexradCache do
|
|||
# Iterate ETS with foldl so frame payloads (~66 MB each) never land on the
|
||||
# calling process's heap — :ets.tab2list() at capacity would allocate ~1.3 GB.
|
||||
defp oldest_key do
|
||||
:ets.foldl(fn
|
||||
{ts, _, _}, nil -> ts
|
||||
{ts, _, _}, oldest -> if DateTime.before?(ts, oldest), do: ts, else: oldest
|
||||
end, nil, @table)
|
||||
:ets.foldl(
|
||||
fn
|
||||
{ts, _, _}, nil -> ts
|
||||
{ts, _, _}, oldest -> if DateTime.before?(ts, oldest), do: ts, else: oldest
|
||||
end,
|
||||
nil,
|
||||
@table
|
||||
)
|
||||
end
|
||||
|
||||
@spec prune_older_than(DateTime.t()) :: non_neg_integer()
|
||||
|
|
|
|||
|
|
@ -134,9 +134,7 @@ defmodule Microwaveprop.Weather.ScalarFile do
|
|||
# leave stale chunks behind.
|
||||
case File.ls(dir) do
|
||||
{:ok, entries} ->
|
||||
for entry <- entries do
|
||||
_ = File.rm(Path.join(dir, entry))
|
||||
end
|
||||
Enum.each(entries, &File.rm!(Path.join(dir, &1)))
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
|
|||
rescue
|
||||
e ->
|
||||
Logger.error("RoverPathProfileWorker crashed for path=#{path.id}: #{Exception.message(e)}")
|
||||
store_failed(path, "exception: #{Exception.message(e)}")
|
||||
_ = store_failed(path, "exception: #{Exception.message(e)}")
|
||||
reraise e, __STACKTRACE__
|
||||
end
|
||||
|
||||
|
|
@ -128,7 +128,7 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
|
|||
|> Repo.update()
|
||||
|> case do
|
||||
{:ok, updated} ->
|
||||
broadcast(updated)
|
||||
_ = broadcast(updated)
|
||||
:ok
|
||||
|
||||
{:error, cs} ->
|
||||
|
|
@ -216,7 +216,7 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
|
|||
|> Repo.update()
|
||||
|> case do
|
||||
{:ok, updated} ->
|
||||
broadcast(updated)
|
||||
_ = broadcast(updated)
|
||||
{:error, message}
|
||||
|
||||
{:error, cs} ->
|
||||
|
|
|
|||
|
|
@ -50,13 +50,14 @@ defmodule MicrowavepropWeb.Api.RateLimiter do
|
|||
"""
|
||||
@spec init_table() :: :ok
|
||||
def init_table do
|
||||
if :ets.whereis(@table) == :undefined do
|
||||
try do
|
||||
:ets.new(@table, [:set, :public, :named_table, write_concurrency: true])
|
||||
rescue
|
||||
ArgumentError -> :ok
|
||||
_ =
|
||||
if :ets.whereis(@table) == :undefined do
|
||||
try do
|
||||
:ets.new(@table, [:set, :public, :named_table, write_concurrency: true])
|
||||
rescue
|
||||
ArgumentError -> :ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
|
|
|||
|
|
@ -105,13 +105,12 @@ defmodule MicrowavepropWeb.ScoresController do
|
|||
|
||||
{lats, lons, score_bytes} =
|
||||
Enum.reduce(scores, {[], [], []}, fn s, {lats_acc, lons_acc, scs_acc} ->
|
||||
{[<<s.lat * 1.0::little-float-32>> | lats_acc],
|
||||
[<<s.lon * 1.0::little-float-32>> | lons_acc],
|
||||
{[<<s.lat * 1.0::little-float-32>> | lats_acc], [<<s.lon * 1.0::little-float-32>> | lons_acc],
|
||||
[<<clamp_score(s.score)::8>> | scs_acc]}
|
||||
end)
|
||||
|
||||
<<header::binary, IO.iodata_to_binary(lats)::binary,
|
||||
IO.iodata_to_binary(lons)::binary, IO.iodata_to_binary(score_bytes)::binary>>
|
||||
<<header::binary, IO.iodata_to_binary(lats)::binary, IO.iodata_to_binary(lons)::binary,
|
||||
IO.iodata_to_binary(score_bytes)::binary>>
|
||||
end
|
||||
|
||||
defp clamp_score(s) when is_integer(s) and s < 0, do: 0
|
||||
|
|
|
|||
|
|
@ -95,22 +95,22 @@ defmodule MicrowavepropWeb.ContactLive.Index do
|
|||
|> Repo.all()
|
||||
|> Map.new()
|
||||
|
||||
counts = Enum.map(1..12, fn m -> {m, Map.get(by_month, m, 0)} end)
|
||||
max_count = counts |> Enum.map(&elem(&1, 1)) |> Enum.max()
|
||||
counts = Enum.map(1..12, fn m -> {m, Map.get(by_month, m, 0)} end)
|
||||
max_count = counts |> Enum.map(&elem(&1, 1)) |> Enum.max()
|
||||
|
||||
Enum.map(counts, fn {month, count} ->
|
||||
x = (month - 1) * @chart_bar_pitch + @chart_bar_offset
|
||||
height = bar_height(count, max_count)
|
||||
Enum.map(counts, fn {month, count} ->
|
||||
x = (month - 1) * @chart_bar_pitch + @chart_bar_offset
|
||||
height = bar_height(count, max_count)
|
||||
|
||||
%{
|
||||
month: month,
|
||||
label: Enum.at(@month_labels, month - 1),
|
||||
count: count,
|
||||
x: x,
|
||||
y: @chart_baseline - height,
|
||||
height: height
|
||||
}
|
||||
end)
|
||||
%{
|
||||
month: month,
|
||||
label: Enum.at(@month_labels, month - 1),
|
||||
count: count,
|
||||
x: x,
|
||||
y: @chart_baseline - height,
|
||||
height: height
|
||||
}
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
|||
|
||||
def handle_event("delete_contact", _params, socket) do
|
||||
if admin?(socket.assigns) do
|
||||
Radio.delete_contact!(socket.assigns.contact)
|
||||
_ = Radio.delete_contact!(socket.assigns.contact)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|
|
|
|||
|
|
@ -24,9 +24,10 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
|||
|> push_navigate(to: ~p"/rover-planning")}
|
||||
|
||||
%Mission{} = mission ->
|
||||
if connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "rover_planning:#{mission.id}")
|
||||
end
|
||||
_ =
|
||||
if connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "rover_planning:#{mission.id}")
|
||||
end
|
||||
|
||||
{:ok,
|
||||
assign(socket,
|
||||
|
|
|
|||
|
|
@ -70,9 +70,6 @@ defmodule MicrowavepropWeb.SkewtLocationResolver do
|
|||
|
||||
{:error, reason} when is_binary(reason) ->
|
||||
{:error, "callsign lookup failed: #{reason}"}
|
||||
|
||||
{:error, _} ->
|
||||
{:error, "callsign lookup failed"}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -83,9 +80,6 @@ defmodule MicrowavepropWeb.SkewtLocationResolver do
|
|||
|
||||
{:error, reason} when is_binary(reason) ->
|
||||
{:error, "geocode failed: #{reason}"}
|
||||
|
||||
{:error, _} ->
|
||||
{:error, "geocode failed"}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ defmodule MicrowavepropWeb.SkewtSvg do
|
|||
ascent line is overlaid on the temperature trace, matching the
|
||||
surface-parcel rendition the SPC sounding viewer publishes.
|
||||
"""
|
||||
@spec render([map()], keyword()) :: iodata()
|
||||
@spec render([map()], keyword()) :: list()
|
||||
def render(profile, opts \\ []) when is_list(profile) do
|
||||
cleaned = clean_profile(profile)
|
||||
parcel = Keyword.get(opts, :parcel_trace, [])
|
||||
|
|
|
|||
|
|
@ -220,5 +220,4 @@ defmodule Mix.Tasks.Calibrate.Aprs144 do
|
|||
end
|
||||
|
||||
defp format_float(f) when is_float(f), do: :erlang.float_to_binary(f, decimals: 4)
|
||||
defp format_float(f), do: to_string(f)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ defmodule Mix.Tasks.Unused do
|
|||
|
||||
use Mix.Task
|
||||
|
||||
@dialyzer {:no_opaque, run: 1}
|
||||
|
||||
# Callbacks that are invoked by the runtime, not by direct function calls.
|
||||
# They look unused to static analysis but are actually called by OTP /
|
||||
# Phoenix / LiveView machinery.
|
||||
|
|
@ -376,6 +378,7 @@ defmodule Mix.Tasks.Unused do
|
|||
end
|
||||
end
|
||||
|
||||
@spec analyze_beam(String.t()) :: {MapSet.t(), MapSet.t()}
|
||||
defp analyze_beam(beam_file) do
|
||||
module = beam_module(beam_file)
|
||||
|
||||
|
|
@ -388,7 +391,7 @@ defmodule Mix.Tasks.Unused do
|
|||
|
||||
{defs, calls}
|
||||
|
||||
{:error, _reason} ->
|
||||
_ ->
|
||||
# Binary module or unreadable — skip
|
||||
{MapSet.new(), MapSet.new()}
|
||||
end
|
||||
|
|
|
|||
1
mix.lock
1
mix.lock
|
|
@ -82,7 +82,6 @@
|
|||
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
|
||||
"tidewave": {:hex, :tidewave, "0.5.6", "91f35540b5599640443f1d3a1c6166bf506e202840261a6344e384e8813c1f64", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "dc82d52b8b6ffc04680544b17cd340c7d4166bb0d63999eb960850526866b533"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||
"uniq": {:hex, :uniq, "0.6.3", "68acff834cce1817b52928ef346662735c5413a4fec9c3b0d4a9126de5b2b489", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "2b2a900d0a20f3a55d3de0bc8150495e4a71255734dfb23889991bda5aca6c7d"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||
|
|
|
|||
|
|
@ -13,26 +13,26 @@ defmodule Microwaveprop.Repo.Migrations.AddPerformanceIndexes do
|
|||
|
||||
# hrrr_profiles.surface_refractivity IS NULL — backfill query scans for nulls
|
||||
create index(:hrrr_profiles, [:surface_refractivity],
|
||||
where: "surface_refractivity IS NULL",
|
||||
name: "hrrr_profiles_surface_refractivity_null_idx"
|
||||
)
|
||||
where: "surface_refractivity IS NULL",
|
||||
name: "hrrr_profiles_surface_refractivity_null_idx"
|
||||
)
|
||||
|
||||
# hrrr_native_profiles.bulk_richardson IS NULL — derive task scans for nulls
|
||||
create index(:hrrr_native_profiles, [:bulk_richardson],
|
||||
where: "bulk_richardson IS NULL AND level_count > 2",
|
||||
name: "hrrr_native_profiles_bulk_richardson_null_idx"
|
||||
)
|
||||
where: "bulk_richardson IS NULL AND level_count > 2",
|
||||
name: "hrrr_native_profiles_bulk_richardson_null_idx"
|
||||
)
|
||||
|
||||
# grid_tasks.claimed_at for status='running' — reclaim-stale query
|
||||
create index(:grid_tasks, [:claimed_at],
|
||||
where: "status = 'running'",
|
||||
name: "grid_tasks_running_claimed_at_idx"
|
||||
)
|
||||
where: "status = 'running'",
|
||||
name: "grid_tasks_running_claimed_at_idx"
|
||||
)
|
||||
|
||||
# rover_mission_paths composite — worker lookup on all 4 columns
|
||||
create index(:rover_mission_paths, [:mission_id, :rover_location_id, :station_id, :band_mhz],
|
||||
name: "rover_mission_paths_lookup_idx"
|
||||
)
|
||||
name: "rover_mission_paths_lookup_idx"
|
||||
)
|
||||
|
||||
# fixed_stations(user_id, position, inserted_at) — WHERE + ORDER BY
|
||||
create index(:fixed_stations, [:user_id, :position, :inserted_at])
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue