mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 18:18:55 +00:00
fix(portal): Fix pagination issues with flows and activities, improve error handling around live tables (#4330)
Fixes issues from logs. Closes #4274 and similar issues for activities. Simplifies error handling for live tables (we just reset filters with a message when they are invalid because just showing an error 422 is not actionable).
This commit is contained in:
@@ -159,6 +159,16 @@ defmodule Domain.Actors.Actor.Query do
|
||||
values: &Domain.Auth.all_providers!/1,
|
||||
fun: &filter_by_identity_provider_id/2
|
||||
},
|
||||
%Domain.Repo.Filter{
|
||||
name: :status,
|
||||
title: "Status",
|
||||
type: :string,
|
||||
values: [
|
||||
{"Enabled", "enabled"},
|
||||
{"Disabled", "disabled"}
|
||||
],
|
||||
fun: &filter_by_status/2
|
||||
},
|
||||
%Domain.Repo.Filter{
|
||||
name: :type,
|
||||
title: "Type",
|
||||
@@ -189,6 +199,14 @@ defmodule Domain.Actors.Actor.Query do
|
||||
}
|
||||
]
|
||||
|
||||
def filter_by_status(queryable, "enabled") do
|
||||
{queryable, dynamic([actors: actors], is_nil(actors.disabled_at))}
|
||||
end
|
||||
|
||||
def filter_by_status(queryable, "disabled") do
|
||||
{queryable, dynamic([actors: actors], not is_nil(actors.disabled_at))}
|
||||
end
|
||||
|
||||
def filter_by_type(queryable, type) do
|
||||
{queryable, dynamic([actors: actors], actors.type == ^type)}
|
||||
end
|
||||
|
||||
@@ -125,7 +125,6 @@ defmodule Domain.Flows do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_flows_permission()) do
|
||||
queryable
|
||||
|> Authorizer.for_subject(Flow, subject)
|
||||
|> Ecto.Query.order_by([flows: flows], desc: flows.inserted_at, desc: flows.id)
|
||||
|> Repo.list(Flow.Query, opts)
|
||||
end
|
||||
end
|
||||
@@ -145,39 +144,24 @@ defmodule Domain.Flows do
|
||||
end
|
||||
end
|
||||
|
||||
def list_flow_activities_for(assoc, ended_after, started_before, subject, opts \\ [])
|
||||
def list_flow_activities_for(assoc, subject, opts \\ [])
|
||||
|
||||
def list_flow_activities_for(
|
||||
%Flow{} = flow,
|
||||
ended_after,
|
||||
started_before,
|
||||
%Auth.Subject{} = subject,
|
||||
opts
|
||||
) do
|
||||
def list_flow_activities_for(%Flow{} = flow, %Auth.Subject{} = subject, opts) do
|
||||
Activity.Query.all()
|
||||
|> Activity.Query.by_flow_id(flow.id)
|
||||
|> list_activities(ended_after, started_before, subject, opts)
|
||||
|> list_activities(subject, opts)
|
||||
end
|
||||
|
||||
def list_flow_activities_for(
|
||||
%Accounts.Account{} = account,
|
||||
ended_after,
|
||||
started_before,
|
||||
%Auth.Subject{} = subject,
|
||||
opts
|
||||
) do
|
||||
def list_flow_activities_for(%Accounts.Account{} = account, %Auth.Subject{} = subject, opts) do
|
||||
Activity.Query.all()
|
||||
|> Activity.Query.by_account_id(account.id)
|
||||
|> list_activities(ended_after, started_before, subject, opts)
|
||||
|> list_activities(subject, opts)
|
||||
end
|
||||
|
||||
defp list_activities(queryable, ended_after, started_before, subject, opts) do
|
||||
defp list_activities(queryable, subject, opts) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_flows_permission()) do
|
||||
queryable
|
||||
|> Activity.Query.by_window_ended_at({:greater_than, ended_after})
|
||||
|> Activity.Query.by_window_started_at({:less_than, started_before})
|
||||
|> Authorizer.for_subject(Activity, subject)
|
||||
|> Ecto.Query.order_by([activities: activities], asc: activities.window_started_at)
|
||||
|> Repo.list(Activity.Query, opts)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,4 +33,31 @@ defmodule Domain.Flows.Activity.Query do
|
||||
{:activities, :asc, :window_started_at},
|
||||
{:activities, :asc, :id}
|
||||
]
|
||||
|
||||
@impl Domain.Repo.Query
|
||||
def filters,
|
||||
do: [
|
||||
%Domain.Repo.Filter{
|
||||
name: :window_within,
|
||||
title: "Window",
|
||||
type: {:range, :datetime},
|
||||
fun: &filter_by_window/2
|
||||
}
|
||||
]
|
||||
|
||||
def filter_by_window(queryable, %Domain.Repo.Filter.Range{from: from, to: nil}) do
|
||||
{queryable, dynamic([activities: activities], ^from <= activities.window_started_at)}
|
||||
end
|
||||
|
||||
def filter_by_window(queryable, %Domain.Repo.Filter.Range{from: nil, to: to}) do
|
||||
{queryable, dynamic([activities: activities], activities.window_ended_at <= ^to)}
|
||||
end
|
||||
|
||||
def filter_by_window(queryable, %Domain.Repo.Filter.Range{from: from, to: to}) do
|
||||
{queryable,
|
||||
dynamic(
|
||||
[activities: activities],
|
||||
^from <= activities.window_started_at and activities.window_ended_at <= ^to
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -87,12 +87,6 @@ defmodule Domain.Repo.Query do
|
||||
def by_range(%Filter.Range{from: from, to: to}, fragment),
|
||||
do: dynamic(^from <= ^fragment and ^fragment <= ^to)
|
||||
|
||||
def by_range(%Filter.Range{to: to}, fragment),
|
||||
do: dynamic(^fragment <= ^to)
|
||||
|
||||
def by_range(%Filter.Range{from: from}, fragment),
|
||||
do: dynamic(^fragment >= ^from)
|
||||
|
||||
@doc """
|
||||
This function is to allow reuse of the filter function in the regular query helpers,
|
||||
it takes a return of a filter function (`{queryable, dynamic}`) and applies it to the queryable.
|
||||
|
||||
@@ -571,15 +571,11 @@ defmodule Domain.FlowsTest do
|
||||
flow: flow,
|
||||
subject: subject
|
||||
} do
|
||||
now = DateTime.utc_now()
|
||||
ended_after = DateTime.add(now, -30, :minute)
|
||||
started_before = DateTime.add(now, 30, :minute)
|
||||
assert {:ok, [], _metadata} =
|
||||
list_flow_activities_for(account, subject)
|
||||
|
||||
assert {:ok, [], _metadata} =
|
||||
list_flow_activities_for(account, ended_after, started_before, subject)
|
||||
|
||||
assert {:ok, [], _metadata} =
|
||||
list_flow_activities_for(flow, ended_after, started_before, subject)
|
||||
list_flow_activities_for(flow, subject)
|
||||
end
|
||||
|
||||
test "does not list flow activities from other accounts", %{
|
||||
@@ -589,15 +585,11 @@ defmodule Domain.FlowsTest do
|
||||
flow = Fixtures.Flows.create_flow()
|
||||
Fixtures.Flows.create_activity(flow: flow)
|
||||
|
||||
now = DateTime.utc_now()
|
||||
ended_after = DateTime.add(now, -30, :minute)
|
||||
started_before = DateTime.add(now, 30, :minute)
|
||||
assert {:ok, [], _metadata} =
|
||||
list_flow_activities_for(account, subject)
|
||||
|
||||
assert {:ok, [], _metadata} =
|
||||
list_flow_activities_for(account, ended_after, started_before, subject)
|
||||
|
||||
assert {:ok, [], _metadata} =
|
||||
list_flow_activities_for(flow, ended_after, started_before, subject)
|
||||
list_flow_activities_for(flow, subject)
|
||||
end
|
||||
|
||||
test "returns ordered by window start time flow activities within a time window", %{
|
||||
@@ -623,49 +615,73 @@ defmodule Domain.FlowsTest do
|
||||
assert {:ok, [], _metadata} =
|
||||
list_flow_activities_for(
|
||||
account,
|
||||
thirty_minutes_in_future,
|
||||
sixty_minutes_in_future,
|
||||
subject
|
||||
subject,
|
||||
filter: [
|
||||
window_within: %Domain.Repo.Filter.Range{
|
||||
from: thirty_minutes_in_future,
|
||||
to: sixty_minutes_in_future
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert {:ok, [], _metadata} =
|
||||
list_flow_activities_for(
|
||||
flow,
|
||||
thirty_minutes_in_future,
|
||||
sixty_minutes_in_future,
|
||||
subject
|
||||
subject,
|
||||
filter: [
|
||||
window_within: %Domain.Repo.Filter.Range{
|
||||
from: thirty_minutes_in_future,
|
||||
to: sixty_minutes_in_future
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert {:ok, [], _metadata} =
|
||||
list_flow_activities_for(
|
||||
account,
|
||||
thirty_minutes_ago,
|
||||
five_minutes_ago,
|
||||
subject
|
||||
subject,
|
||||
filter: [
|
||||
window_within: %Domain.Repo.Filter.Range{
|
||||
from: thirty_minutes_ago,
|
||||
to: five_minutes_ago
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert {:ok, [], _metadata} =
|
||||
list_flow_activities_for(
|
||||
flow,
|
||||
thirty_minutes_ago,
|
||||
five_minutes_ago,
|
||||
subject
|
||||
subject,
|
||||
filter: [
|
||||
window_within: %Domain.Repo.Filter.Range{
|
||||
from: thirty_minutes_ago,
|
||||
to: five_minutes_ago
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert {:ok, [^activity1], _metadata} =
|
||||
list_flow_activities_for(
|
||||
account,
|
||||
five_minutes_ago,
|
||||
now,
|
||||
subject
|
||||
subject,
|
||||
filter: [
|
||||
window_within: %Domain.Repo.Filter.Range{
|
||||
from: five_minutes_ago,
|
||||
to: now
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert {:ok, [^activity1], _metadata} =
|
||||
list_flow_activities_for(
|
||||
flow,
|
||||
five_minutes_ago,
|
||||
now,
|
||||
subject
|
||||
subject,
|
||||
filter: [
|
||||
window_within: %Domain.Repo.Filter.Range{
|
||||
from: five_minutes_ago,
|
||||
to: now
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
activity2 =
|
||||
@@ -678,17 +694,25 @@ defmodule Domain.FlowsTest do
|
||||
assert {:ok, [^activity2, ^activity1], _metadata} =
|
||||
list_flow_activities_for(
|
||||
account,
|
||||
thirty_minutes_ago,
|
||||
now,
|
||||
subject
|
||||
subject,
|
||||
filter: [
|
||||
window_within: %Domain.Repo.Filter.Range{
|
||||
from: thirty_minutes_ago,
|
||||
to: now
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert {:ok, [^activity2, ^activity1], _metadata} =
|
||||
list_flow_activities_for(
|
||||
flow,
|
||||
thirty_minutes_ago,
|
||||
now,
|
||||
subject
|
||||
subject,
|
||||
filter: [
|
||||
window_within: %Domain.Repo.Filter.Range{
|
||||
from: thirty_minutes_ago,
|
||||
to: now
|
||||
}
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
@@ -697,19 +721,15 @@ defmodule Domain.FlowsTest do
|
||||
flow: flow,
|
||||
subject: subject
|
||||
} do
|
||||
now = DateTime.utc_now()
|
||||
ended_after = DateTime.add(now, -30, :minute)
|
||||
started_before = DateTime.add(now, 30, :minute)
|
||||
|
||||
subject = Fixtures.Auth.remove_permissions(subject)
|
||||
|
||||
assert list_flow_activities_for(account, ended_after, started_before, subject) ==
|
||||
assert list_flow_activities_for(account, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
reason: :missing_permissions,
|
||||
missing_permissions: [Flows.Authorizer.manage_flows_permission()]}}
|
||||
|
||||
assert list_flow_activities_for(flow, ended_after, started_before, subject) ==
|
||||
assert list_flow_activities_for(flow, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
reason: :missing_permissions,
|
||||
|
||||
@@ -30,7 +30,7 @@ defmodule Domain.Jobs.Executors.GlobalTest do
|
||||
|
||||
test "registers itself as a leader if there is no global name registered" do
|
||||
assert {:ok, pid} = start_link({{__MODULE__, :send_test_message}, 25, test_pid: self()})
|
||||
assert_receive {:executed, ^pid, _time}, 500
|
||||
assert_receive {:executed, ^pid, _time}, 1000
|
||||
name = {Domain.Jobs.Executors.Global, __MODULE__, :send_test_message}
|
||||
assert :global.whereis_name(name) == pid
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ defmodule Web.ErrorController do
|
||||
def show(_conn, params) do
|
||||
case params["code"] do
|
||||
"404" -> raise Web.LiveErrors.NotFoundError
|
||||
"422" -> raise Web.LiveErrors.InvalidRequestError
|
||||
"500" -> raise "internal server error"
|
||||
end
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=client-width, initial-scale=1" />
|
||||
<link rel="icon" href={~p"/favicon.ico"} sizes="any" />
|
||||
<link rel="icon" href={~p"/images/favicon.svg"} type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href={~p"/images/apple-touch-icon.png"} />
|
||||
<link rel="manifest" href={~p"/site.webmanifest"} />
|
||||
<meta name="theme-color" content="#331700" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<title>422 Error</title>
|
||||
<link
|
||||
phx-track-static
|
||||
rel="stylesheet"
|
||||
nonce={@conn.private.csp_nonce}
|
||||
href={~p"/assets/app.css"}
|
||||
/>
|
||||
<script
|
||||
defer
|
||||
phx-track-static
|
||||
type="text/javascript"
|
||||
nonce={@conn.private.csp_nonce}
|
||||
src={~p"/assets/app.js"}
|
||||
>
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-neutral-50">
|
||||
<div class="flex items-center h-screen p-16 bg-gray-50">
|
||||
<div class="container mx-auto flex flex-col items-center">
|
||||
<div class="flex flex-col gap-6 max-w-md text-center">
|
||||
<img src="/images/logo.svg" class="mr-5 h-32" alt="Firezone Logo" />
|
||||
<h2 class="font-extrabold text-9xl text-primary-600">
|
||||
<span class="sr-only">Error</span>422
|
||||
</h2>
|
||||
<p class="text-2xl md:text-3xl">
|
||||
Something went wrong. We've already been notified and will get it fixed as soon as possible.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -9,6 +9,8 @@ defmodule Web.Actors.Index do
|
||||
|> assign(page_title: "Actors")
|
||||
|> assign_live_table("actors",
|
||||
query_module: Actors.Actor.Query,
|
||||
# TODO[bmanifold]: Enable this filter once we start collapsing them
|
||||
hide_filters: [:type],
|
||||
sortable_fields: [
|
||||
{:actors, :name}
|
||||
],
|
||||
|
||||
@@ -36,8 +36,6 @@ defmodule Web.Flows.DownloadActivities do
|
||||
with {:ok, activities, activities_metadata} <-
|
||||
Flows.list_flow_activities_for(
|
||||
flow,
|
||||
flow.inserted_at,
|
||||
flow.expires_at,
|
||||
conn.assigns.subject,
|
||||
page: [cursor: cursor, limit: 100]
|
||||
),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
defmodule Web.Flows.Show do
|
||||
use Web, :live_view
|
||||
import Web.Policies.Components
|
||||
alias Domain.{Flows, Flows}
|
||||
alias Domain.{Accounts, Flows}
|
||||
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
with {:ok, flow} <-
|
||||
with true <- Accounts.flow_activities_enabled?(socket.assigns.account),
|
||||
{:ok, flow} <-
|
||||
Flows.fetch_flow_by_id(id, socket.assigns.subject,
|
||||
preload: [
|
||||
policy: [:resource, :actor_group],
|
||||
@@ -16,11 +17,18 @@ defmodule Web.Flows.Show do
|
||||
last_used_connectivity_type = get_last_used_connectivity_type(flow, socket.assigns.subject)
|
||||
|
||||
socket =
|
||||
assign(socket,
|
||||
socket
|
||||
|> assign(
|
||||
page_title: "Flow #{flow.id}",
|
||||
flow: flow,
|
||||
last_used_connectivity_type: last_used_connectivity_type
|
||||
)
|
||||
|> assign_live_table("activities",
|
||||
query_module: Flows.Activity.Query,
|
||||
sortable_fields: [],
|
||||
limit: 10,
|
||||
callback: &handle_activities_update!/2
|
||||
)
|
||||
|
||||
{:ok, socket}
|
||||
else
|
||||
@@ -35,6 +43,22 @@ defmodule Web.Flows.Show do
|
||||
end
|
||||
end
|
||||
|
||||
def handle_params(params, uri, socket) do
|
||||
socket = handle_live_tables_params(socket, params, uri)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_activities_update!(socket, list_opts) do
|
||||
with {:ok, activities, metadata} <-
|
||||
Flows.list_flow_activities_for(socket.assigns.flow, socket.assigns.subject, list_opts) do
|
||||
{:ok,
|
||||
assign(socket,
|
||||
activities: activities,
|
||||
activities_metadata: metadata
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.breadcrumbs account={@account}>
|
||||
@@ -116,6 +140,49 @@ defmodule Web.Flows.Show do
|
||||
</.vertical_table>
|
||||
</:content>
|
||||
</.section>
|
||||
|
||||
<.section>
|
||||
<:title>Metrics</:title>
|
||||
<:help>
|
||||
Pre-aggregated metrics for this flow.
|
||||
</:help>
|
||||
<:content>
|
||||
<.live_table
|
||||
id="activities"
|
||||
rows={@activities}
|
||||
row_id={&"activities-#{&1.id}"}
|
||||
filters={@filters_by_table_id["activities"]}
|
||||
filter={@filter_form_by_table_id["activities"]}
|
||||
ordered_by={@order_by_table_id["activities"]}
|
||||
metadata={@activities_metadata}
|
||||
>
|
||||
<:col :let={activity} label="STARTED AT">
|
||||
<.relative_datetime datetime={activity.window_started_at} />
|
||||
</:col>
|
||||
<:col :let={activity} label="ENDED AT">
|
||||
<.relative_datetime datetime={activity.window_ended_at} />
|
||||
</:col>
|
||||
<:col :let={activity} label="DESTINATION">
|
||||
<%= activity.destination %>
|
||||
</:col>
|
||||
<:col :let={activity} label="CONNECTIVITY TYPE">
|
||||
<%= activity.connectivity_type %>
|
||||
</:col>
|
||||
<:col :let={activity} label="RX">
|
||||
<%= Sizeable.filesize(activity.rx_bytes) %>
|
||||
</:col>
|
||||
<:col :let={activity} label="TX">
|
||||
<%= Sizeable.filesize(activity.tx_bytes) %>
|
||||
</:col>
|
||||
<:empty>
|
||||
<div class="text-center text-neutral-500 p-4">No metrics to display.</div>
|
||||
</:empty>
|
||||
</.live_table>
|
||||
</:content>
|
||||
</.section>
|
||||
"""
|
||||
end
|
||||
|
||||
def handle_event(event, params, socket) when event in ["paginate", "order_by", "filter"],
|
||||
do: handle_live_table_event(event, params, socket)
|
||||
end
|
||||
|
||||
@@ -29,7 +29,7 @@ defmodule Web.Groups.EditActors do
|
||||
sortable_fields: [
|
||||
{:actors, :name}
|
||||
],
|
||||
hide_filters: [:type, :provider_id],
|
||||
hide_filters: [:type, :provider_id, :status],
|
||||
callback: &handle_actors_update!/2
|
||||
)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ defmodule Web.Groups.Show do
|
||||
sortable_fields: [
|
||||
{:actors, :name}
|
||||
],
|
||||
hide_filters: [:type, :provider_id],
|
||||
hide_filters: [:type, :status, :provider_id],
|
||||
callback: &handle_actors_update!/2
|
||||
)
|
||||
|> assign_live_table("policies",
|
||||
|
||||
@@ -7,13 +7,4 @@ defmodule Web.LiveErrors do
|
||||
def actions(_exception), do: []
|
||||
end
|
||||
end
|
||||
|
||||
defmodule InvalidRequestError do
|
||||
defexception message: "Unprocessable Entity"
|
||||
|
||||
defimpl Plug.Exception do
|
||||
def status(_exception), do: 422
|
||||
def actions(_exception), do: []
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -103,6 +103,81 @@ defmodule Web.LiveTable do
|
||||
"""
|
||||
end
|
||||
|
||||
def datetime_input(assigns) do
|
||||
~H"""
|
||||
<div phx-feedback-for={@field.name} class={["flex items-center"]}>
|
||||
<input
|
||||
placeholder={"#{@filter.title} Started At"}
|
||||
type="date"
|
||||
name={"#{@field.name}[#{@from_or_to}][date]"}
|
||||
id={"#{@field.id}[#{@from_or_to}][date]"}
|
||||
value={normalize_value("date", Map.get(@field.value || %{}, @from_or_to))}
|
||||
max={@max}
|
||||
min="2023-01-01"
|
||||
autocomplete="off"
|
||||
class={[
|
||||
"bg-neutral-50 border border-neutral-300 text-neutral-900 text-sm rounded",
|
||||
"block w-1/2 mr-1",
|
||||
"phx-no-feedback:border-neutral-300",
|
||||
"disabled:bg-neutral-50 disabled:text-neutral-500 disabled:border-neutral-200 disabled:shadow-none",
|
||||
"focus:outline-none focus:border-1 focus:ring-0",
|
||||
"border-neutral-300",
|
||||
@field.errors != [] && "border-rose-400"
|
||||
]}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
step="1"
|
||||
placeholder={"#{@filter.title} Started At"}
|
||||
name={@field.name <> "[#{@from_or_to}][time]"}
|
||||
id={@field.id <> "[#{@from_or_to}][time]"}
|
||||
value={normalize_value("time", Map.get(@field.value || %{}, @from_or_to)) || "00:00:00"}
|
||||
class={[
|
||||
"bg-neutral-50 border border-neutral-300 text-neutral-900 text-sm rounded",
|
||||
"block w-1/2",
|
||||
"phx-no-feedback:border-neutral-300",
|
||||
"disabled:bg-neutral-50 disabled:text-neutral-500 disabled:border-neutral-200 disabled:shadow-none",
|
||||
"focus:outline-none focus:border-1 focus:ring-0",
|
||||
"border-neutral-300",
|
||||
@field.errors != [] && "border-rose-400"
|
||||
]}
|
||||
/>
|
||||
<.error :for={msg <- @field.errors} data-validation-error-for={@field.name}>
|
||||
<%= msg %>
|
||||
</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp normalize_value("date", %DateTime{} = datetime),
|
||||
do: DateTime.to_date(datetime) |> Date.to_iso8601()
|
||||
|
||||
defp normalize_value("time", %DateTime{} = datetime),
|
||||
do: DateTime.to_time(datetime) |> Time.to_iso8601()
|
||||
|
||||
defp normalize_value(_, nil),
|
||||
do: nil
|
||||
|
||||
defp filter(%{filter: %{type: {:range, :datetime}}} = assigns) do
|
||||
~H"""
|
||||
<div class="flex items-center">
|
||||
<.datetime_input
|
||||
field={@form[@filter.name]}
|
||||
filter={@filter}
|
||||
from_or_to={:from}
|
||||
max={Date.utc_today()}
|
||||
/>
|
||||
<div class="mx-2 text-neutral-500">to</div>
|
||||
<.datetime_input
|
||||
field={@form[@filter.name]}
|
||||
filter={@filter}
|
||||
from_or_to={:to}
|
||||
max={Date.utc_today()}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp filter(%{filter: %{type: {:string, :websearch}}} = assigns) do
|
||||
~H"""
|
||||
<div class={["relative w-full"]} phx-feedback-for={@form[@filter.name].name}>
|
||||
@@ -432,7 +507,8 @@ defmodule Web.LiveTable do
|
||||
|
||||
case callback.(socket, list_opts) do
|
||||
{:error, _reason} ->
|
||||
push_navigate(socket, to: socket.assigns.uri)
|
||||
uri = URI.parse(socket.assigns.uri)
|
||||
push_navigate(socket, to: uri.path)
|
||||
|
||||
{:ok, socket} ->
|
||||
:ok = maybe_notify_test_pid(id)
|
||||
@@ -458,75 +534,92 @@ defmodule Web.LiveTable do
|
||||
with the new state.
|
||||
"""
|
||||
def handle_live_tables_params(socket, params, uri) do
|
||||
socket =
|
||||
Enum.reduce(socket.assigns.live_table_ids, socket, fn id, socket ->
|
||||
handle_live_table_params(socket, params, id)
|
||||
end)
|
||||
socket = assign(socket, uri: uri)
|
||||
|
||||
assign(socket, uri: uri)
|
||||
Enum.reduce(socket.assigns.live_table_ids, socket, fn id, socket ->
|
||||
handle_live_table_params(socket, params, id)
|
||||
end)
|
||||
end
|
||||
|
||||
defp handle_live_table_params(socket, params, id) do
|
||||
enforced_filters = Map.fetch!(socket.assigns.enforced_filters_by_table_id, id)
|
||||
filter = enforced_filters ++ params_to_filter(id, params)
|
||||
sortable_fields = Map.fetch!(socket.assigns.sortable_fields_by_table_id, id)
|
||||
limit = Map.fetch!(socket.assigns.limit_by_table_id, id)
|
||||
page = params_to_page(id, limit, params)
|
||||
|
||||
order_by =
|
||||
socket.assigns.sortable_fields_by_table_id
|
||||
|> Map.fetch!(id)
|
||||
|> params_to_order_by(id, params)
|
||||
with {:ok, filter} <- params_to_filter(id, params),
|
||||
filter = enforced_filters ++ filter,
|
||||
{:ok, page} <- params_to_page(id, limit, params),
|
||||
{:ok, order_by} <- params_to_order_by(sortable_fields, id, params) do
|
||||
list_opts = [
|
||||
page: page,
|
||||
filter: filter,
|
||||
order_by: List.wrap(order_by)
|
||||
]
|
||||
|
||||
list_opts = [
|
||||
page: page,
|
||||
filter: filter,
|
||||
order_by: List.wrap(order_by)
|
||||
]
|
||||
case maybe_apply_callback(socket, id, list_opts) do
|
||||
{:ok, socket} ->
|
||||
socket
|
||||
|> assign(
|
||||
filter_form_by_table_id:
|
||||
put_table_state(
|
||||
socket,
|
||||
id,
|
||||
:filter_form_by_table_id,
|
||||
filter_to_form(filter, id)
|
||||
),
|
||||
order_by_table_id:
|
||||
put_table_state(
|
||||
socket,
|
||||
id,
|
||||
:order_by_table_id,
|
||||
order_by
|
||||
),
|
||||
list_opts_by_table_id:
|
||||
put_table_state(
|
||||
socket,
|
||||
id,
|
||||
:list_opts_by_table_id,
|
||||
list_opts
|
||||
)
|
||||
)
|
||||
|
||||
case maybe_apply_callback(socket, id, list_opts) do
|
||||
{:ok, socket} ->
|
||||
socket
|
||||
|> assign(
|
||||
filter_form_by_table_id:
|
||||
put_table_state(
|
||||
socket,
|
||||
id,
|
||||
:filter_form_by_table_id,
|
||||
filter_to_form(filter, id)
|
||||
),
|
||||
order_by_table_id:
|
||||
put_table_state(
|
||||
socket,
|
||||
id,
|
||||
:order_by_table_id,
|
||||
order_by
|
||||
),
|
||||
list_opts_by_table_id:
|
||||
put_table_state(
|
||||
socket,
|
||||
id,
|
||||
:list_opts_by_table_id,
|
||||
list_opts
|
||||
)
|
||||
)
|
||||
{:error, :invalid_cursor} ->
|
||||
message = "The page was reset due to invalid pagination cursor."
|
||||
reset_live_table_params(socket, id, message)
|
||||
|
||||
{:error, :invalid_cursor} ->
|
||||
raise Web.LiveErrors.InvalidRequestError
|
||||
{:error, {:unknown_filter, _metadata}} ->
|
||||
message = "The page was reset due to use of undefined pagination filter."
|
||||
reset_live_table_params(socket, id, message)
|
||||
|
||||
{:error, {:unknown_filter, _metadata}} ->
|
||||
raise Web.LiveErrors.InvalidRequestError
|
||||
{:error, {:invalid_type, _metadata}} ->
|
||||
message = "The page was reset due to invalid value of a pagination filter."
|
||||
reset_live_table_params(socket, id, message)
|
||||
|
||||
{:error, {:invalid_type, _metadata}} ->
|
||||
raise Web.LiveErrors.InvalidRequestError
|
||||
{:error, {:invalid_value, _metadata}} ->
|
||||
message = "The page was reset due to invalid value of a pagination filter."
|
||||
reset_live_table_params(socket, id, message)
|
||||
|
||||
{:error, {:invalid_value, _metadata}} ->
|
||||
raise Web.LiveErrors.InvalidRequestError
|
||||
|
||||
{:error, _reason} ->
|
||||
raise Web.LiveErrors.NotFoundError
|
||||
{:error, _reason} ->
|
||||
raise Web.LiveErrors.NotFoundError
|
||||
end
|
||||
else
|
||||
{:error, :invalid_filter} ->
|
||||
message = "The page was reset due to invalid pagination filter."
|
||||
reset_live_table_params(socket, id, message)
|
||||
end
|
||||
end
|
||||
|
||||
defp reset_live_table_params(socket, id, message) do
|
||||
{:noreply, socket} =
|
||||
socket
|
||||
|> put_flash(:error, message)
|
||||
|> update_query_params(fn query_params ->
|
||||
Map.reject(query_params, fn {key, _} -> String.starts_with?(key, "#{id}_") end)
|
||||
end)
|
||||
|
||||
socket
|
||||
end
|
||||
|
||||
defp maybe_apply_callback(socket, id, list_opts) do
|
||||
previous_list_opts = Map.get(socket.assigns[:list_opts_by_table_id] || %{}, id, [])
|
||||
|
||||
@@ -557,19 +650,59 @@ defmodule Web.LiveTable do
|
||||
|
||||
defp params_to_page(id, limit, params) do
|
||||
if cursor = Map.get(params, "#{id}_cursor") do
|
||||
[cursor: cursor, limit: limit]
|
||||
{:ok, [cursor: cursor, limit: limit]}
|
||||
else
|
||||
[limit: limit]
|
||||
{:ok, [limit: limit]}
|
||||
end
|
||||
end
|
||||
|
||||
defp params_to_filter(id, params) do
|
||||
for {key, value} <- Map.get(params, "#{id}_filter", []),
|
||||
value != "" do
|
||||
{String.to_existing_atom(key), value}
|
||||
params
|
||||
|> Map.get("#{id}_filter", [])
|
||||
|> Enum.reduce_while({:ok, []}, fn {key, value}, {:ok, acc} ->
|
||||
case cast_filter(value) do
|
||||
{:ok, nil} -> {:cont, acc}
|
||||
{:ok, value} -> {:cont, {:ok, [{String.to_existing_atom(key), value}] ++ acc}}
|
||||
{:error, reason} -> {:halt, {:error, reason}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp cast_filter(%{"from" => from, "to" => to}) do
|
||||
with {:ok, from, 0} <- DateTime.from_iso8601(from),
|
||||
{:ok, to, 0} <- DateTime.from_iso8601(to) do
|
||||
{:ok, %Domain.Repo.Filter.Range{from: from, to: to}}
|
||||
else
|
||||
_other -> {:error, :invalid_filter}
|
||||
end
|
||||
rescue
|
||||
ArgumentError -> []
|
||||
end
|
||||
|
||||
defp cast_filter(%{"to" => to}) do
|
||||
with {:ok, to, 0} <- DateTime.from_iso8601(to) do
|
||||
{:ok, %Domain.Repo.Filter.Range{to: to}}
|
||||
else
|
||||
_other -> {:error, :invalid_filter}
|
||||
end
|
||||
end
|
||||
|
||||
defp cast_filter(%{"from" => from}) do
|
||||
with {:ok, from, 0} <- DateTime.from_iso8601(from) do
|
||||
{:ok, %Domain.Repo.Filter.Range{from: from}}
|
||||
else
|
||||
_other -> {:error, :invalid_filter}
|
||||
end
|
||||
end
|
||||
|
||||
defp cast_filter("") do
|
||||
{:ok, nil}
|
||||
end
|
||||
|
||||
defp cast_filter(binary) when is_binary(binary) do
|
||||
{:ok, binary}
|
||||
end
|
||||
|
||||
defp cast_filter(_other) do
|
||||
{:error, :invalid_filter}
|
||||
end
|
||||
|
||||
@doc false
|
||||
@@ -582,8 +715,11 @@ defmodule Web.LiveTable do
|
||||
end
|
||||
|
||||
defp params_to_order_by(sortable_fields, id, params) do
|
||||
Map.get(params, "#{id}_order_by", "")
|
||||
|> parse_order_by(sortable_fields)
|
||||
order_by =
|
||||
Map.get(params, "#{id}_order_by", "")
|
||||
|> parse_order_by(sortable_fields)
|
||||
|
||||
{:ok, order_by}
|
||||
end
|
||||
|
||||
defp parse_order_by(order_by, sortable_fields) do
|
||||
@@ -679,16 +815,61 @@ defmodule Web.LiveTable do
|
||||
end
|
||||
|
||||
defp put_filter_to_params(params, id, filter) do
|
||||
filter_params =
|
||||
for {key, value} <- filter,
|
||||
value != "",
|
||||
value != "__all__",
|
||||
into: %{} do
|
||||
{"#{id}_filter[#{key}]", value}
|
||||
end
|
||||
filter_params = flatten_filter(filter, "#{id}_filter", %{})
|
||||
|
||||
params
|
||||
|> Map.reject(fn {key, _} -> String.starts_with?(key, "#{id}_filter") end)
|
||||
|> Map.merge(filter_params)
|
||||
end
|
||||
|
||||
defp flatten_filter([], _key_prefix, acc) do
|
||||
acc
|
||||
end
|
||||
|
||||
defp flatten_filter(map, key_prefix, acc) when is_map(map) do
|
||||
flatten_filter(Map.to_list(map), key_prefix, acc)
|
||||
end
|
||||
|
||||
defp flatten_filter([{_key, ""} | rest], key_prefix, acc) do
|
||||
flatten_filter(rest, key_prefix, acc)
|
||||
end
|
||||
|
||||
defp flatten_filter([{_key, "__all__"} | rest], key_prefix, acc) do
|
||||
flatten_filter(rest, key_prefix, acc)
|
||||
end
|
||||
|
||||
defp flatten_filter([{key, %{"date" => _} = datetime_range_filter} | rest], key_prefix, acc) do
|
||||
if value = normalize_datetime_filter(datetime_range_filter) do
|
||||
flatten_filter(rest, key_prefix, Map.put(acc, "#{key_prefix}[#{key}]", value))
|
||||
else
|
||||
flatten_filter(rest, key_prefix, acc)
|
||||
end
|
||||
end
|
||||
|
||||
defp flatten_filter([{key, value} | rest], key_prefix, acc)
|
||||
when is_list(value) or is_map(value) do
|
||||
acc = Map.merge(acc, flatten_filter(value, "#{key_prefix}[#{key}]", %{}))
|
||||
flatten_filter(rest, key_prefix, acc)
|
||||
end
|
||||
|
||||
defp flatten_filter([{key, value} | rest], key_prefix, acc) do
|
||||
flatten_filter(rest, key_prefix, Map.put(acc, "#{key_prefix}[#{key}]", value))
|
||||
end
|
||||
|
||||
defp normalize_datetime_filter(params) do
|
||||
with {:ok, date} <- Date.from_iso8601(params["date"]),
|
||||
{:ok, time} <- normalize_time_filter(params["time"] || "00:00:00") do
|
||||
DateTime.new!(date, time) |> DateTime.to_iso8601()
|
||||
else
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_time_filter(time) when byte_size(time) == 5 do
|
||||
Time.from_iso8601(time <> ":00")
|
||||
end
|
||||
|
||||
defp normalize_time_filter(time) do
|
||||
Time.from_iso8601(time)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,16 +10,6 @@ defmodule Web.ErrorHTMLTest do
|
||||
assert body =~ "Sorry, we couldn't find this page"
|
||||
end
|
||||
|
||||
test "renders 422.html", %{conn: conn} do
|
||||
{_code, _headers, body} =
|
||||
assert_error_sent 422, fn ->
|
||||
get(conn, ~p"/error/422")
|
||||
end
|
||||
|
||||
assert body =~ "Something went wrong"
|
||||
assert body =~ "We've already been notified and will get it fixed as soon as possible"
|
||||
end
|
||||
|
||||
test "renders 500.html", %{conn: conn} do
|
||||
{_code, _headers, body} =
|
||||
assert_error_sent 500, fn ->
|
||||
|
||||
@@ -164,4 +164,39 @@ defmodule Web.Live.Flows.ShowTest do
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
test "renders activities table", %{
|
||||
account: account,
|
||||
flow: flow,
|
||||
identity: identity,
|
||||
conn: conn
|
||||
} do
|
||||
activity =
|
||||
Fixtures.Flows.create_activity(
|
||||
account: account,
|
||||
flow: flow,
|
||||
window_started_at: DateTime.truncate(flow.inserted_at, :second),
|
||||
window_ended_at: DateTime.truncate(flow.expires_at, :second),
|
||||
tx_bytes: 1024 * 1024 * 1024 * 42
|
||||
)
|
||||
|
||||
{:ok, lv, _html} =
|
||||
conn
|
||||
|> authorize_conn(identity)
|
||||
|> live(~p"/#{account}/flows/#{flow}")
|
||||
|
||||
[row] =
|
||||
lv
|
||||
|> element("#activities")
|
||||
|> render()
|
||||
|> table_to_map()
|
||||
|
||||
assert row["started at"]
|
||||
assert row["ended at"]
|
||||
|
||||
assert row["connectivity type"] == to_string(activity.connectivity_type)
|
||||
assert row["destination"] == to_string(activity.destination)
|
||||
assert row["rx"] == "#{activity.rx_bytes} B"
|
||||
assert row["tx"] == "42 GB"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -474,7 +474,7 @@ defmodule Web.LiveTableTest do
|
||||
|
||||
socket =
|
||||
%Phoenix.LiveView.Socket{
|
||||
assigns: %{subject: subject, uri: "/current_uri", __changed__: %{}}
|
||||
assigns: %{subject: subject, uri: "http://foo.bar/current_uri", __changed__: %{}}
|
||||
}
|
||||
|> assign_live_table("table-id",
|
||||
query_module: Actors.Actor.Query,
|
||||
@@ -559,7 +559,38 @@ defmodule Web.LiveTableTest do
|
||||
refute_receive {:callback, _socket, _list_opts}
|
||||
end
|
||||
|
||||
test "raises if the callback returns an error" do
|
||||
test "raises if the table params are invalid" do
|
||||
subject = Fixtures.Auth.create_subject()
|
||||
|
||||
socket =
|
||||
%Phoenix.LiveView.Socket{
|
||||
assigns: %{subject: subject, uri: "/current_uri", __changed__: %{}, flash: %{}}
|
||||
}
|
||||
|
||||
for {reason, message} <- [
|
||||
{:invalid_cursor, "The page was reset due to invalid pagination cursor."},
|
||||
{{:unknown_filter, []},
|
||||
"The page was reset due to use of undefined pagination filter."},
|
||||
{{:invalid_type, []},
|
||||
"The page was reset due to invalid value of a pagination filter."},
|
||||
{{:invalid_value, []},
|
||||
"The page was reset due to invalid value of a pagination filter."}
|
||||
] do
|
||||
socket =
|
||||
assign_live_table(socket, "table-id",
|
||||
query_module: Actors.Actor.Query,
|
||||
sortable_fields: [
|
||||
{:actors, :name}
|
||||
],
|
||||
callback: fn _socket, _list_opts -> {:error, reason} end
|
||||
)
|
||||
|
||||
socket = handle_live_tables_params(socket, %{}, "/foo")
|
||||
assert socket.assigns.flash == %{"error" => message}
|
||||
end
|
||||
end
|
||||
|
||||
test "raises if the callback returns a generic error" do
|
||||
subject = Fixtures.Auth.create_subject()
|
||||
|
||||
socket =
|
||||
@@ -569,11 +600,7 @@ defmodule Web.LiveTableTest do
|
||||
|
||||
for {reason, exception} <- [
|
||||
{:not_found, Web.LiveErrors.NotFoundError},
|
||||
{:unauthorized, Web.LiveErrors.NotFoundError},
|
||||
{:invalid_cursor, Web.LiveErrors.InvalidRequestError},
|
||||
{{:unknown_filter, []}, Web.LiveErrors.InvalidRequestError},
|
||||
{{:invalid_type, []}, Web.LiveErrors.InvalidRequestError},
|
||||
{{:invalid_value, []}, Web.LiveErrors.InvalidRequestError}
|
||||
{:unauthorized, Web.LiveErrors.NotFoundError}
|
||||
] do
|
||||
socket =
|
||||
assign_live_table(socket, "table-id",
|
||||
|
||||
Reference in New Issue
Block a user