feat(portal): Allow filtering clients by presence and deleting them (#7078)

Closes #7073

<img width="1434" alt="Screenshot 2024-10-16 at 12 40 50 PM"
src="https://github.com/user-attachments/assets/2c03f38c-c67e-49db-9453-e23651c8d61c">

<img width="1154" alt="Screenshot 2024-10-16 at 12 47 37 PM"
src="https://github.com/user-attachments/assets/da519458-1447-4dfe-9cef-536bf7760ce2">
This commit is contained in:
Andrew Dryga
2024-10-16 13:29:24 -06:00
committed by GitHub
parent f461112bb7
commit 37ef2cb591
11 changed files with 248 additions and 36 deletions

View File

@@ -123,16 +123,23 @@ defmodule Domain.Clients do
|> Enum.map(& &1.account_id)
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
|> Enum.reduce(%{}, fn account_id, acc ->
connected_clients = account_id |> account_clients_presence_topic() |> Presence.list()
Map.merge(acc, connected_clients)
|> Enum.reduce([], fn account_id, acc ->
connected_client_ids = online_client_ids(account_id)
connected_client_ids ++ acc
end)
Enum.map(clients, fn client ->
%{client | online?: Map.has_key?(connected_clients, client.id)}
%{client | online?: client.id in connected_clients}
end)
end
def online_client_ids(account_id) do
account_id
|> account_clients_presence_topic()
|> Presence.list()
|> Map.keys()
end
def change_client(%Client{} = client, attrs \\ %{}) do
Client.Changeset.update(client, attrs)
end

View File

@@ -123,6 +123,16 @@ defmodule Domain.Clients.Client.Query do
],
fun: &filter_by_verification/2
},
%Domain.Repo.Filter{
name: :presence,
title: "Presence",
type: :string,
values: [
{"Online", "online"},
{"Offline", "offline"}
],
fun: &filter_by_presence/2
},
%Domain.Repo.Filter{
name: :client_or_actor_name,
title: "Client Name or Actor Name",
@@ -143,6 +153,40 @@ defmodule Domain.Clients.Client.Query do
{queryable, dynamic([clients: clients], is_nil(clients.verified_at))}
end
def filter_by_presence(queryable, "online") do
ids =
queryable
|> fetch_queried_account_id()
|> Domain.Clients.online_client_ids()
{queryable, dynamic([clients: clients], clients.id in ^ids)}
end
def filter_by_presence(queryable, "offline") do
ids =
queryable
|> fetch_queried_account_id()
|> Domain.Clients.online_client_ids()
{queryable, dynamic([clients: clients], clients.id not in ^ids)}
end
# there is no easy way to pass additional data to our filters right now so we
# extract the account_id from the queryable instead
defp fetch_queried_account_id(queryable) do
Enum.find_value(queryable.wheres, fn
%Ecto.Query.BooleanExpr{
op: :and,
expr: {:==, _, [{{_, _, [_, :account_id]}, _, _}, {:^, _, [b_idx]}]},
params: [{account_id, {b_idx, :account_id}}]
} ->
account_id
_ ->
nil
end)
end
def filter_by_client_or_actor_name(queryable, name) do
queryable =
with_named_binding(queryable, :actor, fn queryable, binding ->

View File

@@ -348,10 +348,57 @@ IO.puts("")
name: "FZ User iPhone",
external_id: Ecto.UUID.generate(),
public_key: :crypto.strong_rand_bytes(32) |> Base.encode64(),
last_seen_user_agent: "iOS/12.7 (iPhone) connlib/0.7.412",
identifier_for_vendor: "APPL-#{Ecto.UUID.generate()}"
},
unprivileged_subject
%{
unprivileged_subject
| context: %{unprivileged_subject.context | user_agent: "iOS/12.7 (iPhone) connlib/0.7.412"}
}
)
{:ok, _user_android_phone} =
Domain.Clients.upsert_client(
%{
name: "FZ User Android",
external_id: Ecto.UUID.generate(),
public_key: :crypto.strong_rand_bytes(32) |> Base.encode64(),
identifier_for_vendor: "GOOG-#{Ecto.UUID.generate()}"
},
%{
unprivileged_subject
| context: %{unprivileged_subject.context | user_agent: "Android/14 connlib/0.7.412"}
}
)
{:ok, _user_windows_laptop} =
Domain.Clients.upsert_client(
%{
name: "FZ User Surface",
external_id: Ecto.UUID.generate(),
public_key: :crypto.strong_rand_bytes(32) |> Base.encode64(),
device_uuid: "WIN-#{Ecto.UUID.generate()}"
},
%{
unprivileged_subject
| context: %{
unprivileged_subject.context
| user_agent: "Windows/10.0.22631 connlib/0.7.412"
}
}
)
{:ok, _user_linux_laptop} =
Domain.Clients.upsert_client(
%{
name: "FZ User Rendering Station",
external_id: Ecto.UUID.generate(),
public_key: :crypto.strong_rand_bytes(32) |> Base.encode64(),
device_uuid: "UB-#{Ecto.UUID.generate()}"
},
%{
unprivileged_subject
| context: %{unprivileged_subject.context | user_agent: "Ubuntu/22.4.0 connlib/0.7.412"}
}
)
{:ok, _admin_iphone} =
@@ -360,11 +407,13 @@ IO.puts("")
name: "FZ Admin Laptop",
external_id: Ecto.UUID.generate(),
public_key: :crypto.strong_rand_bytes(32) |> Base.encode64(),
last_seen_user_agent: "Mac OS/14.5 connlib/0.7.412",
device_serial: "FVFHF246Q72Z",
device_uuid: "#{Ecto.UUID.generate()}"
},
admin_subject
%{
admin_subject
| context: %{admin_subject.context | user_agent: "Mac OS/14.5 connlib/0.7.412"}
}
)
IO.puts("Clients created")

View File

@@ -606,14 +606,33 @@ defmodule Web.CoreComponents do
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 30 30"
viewBox="0 0 24 24"
class={["inline-block", @class]}
{@rest}
>
<path d="M1,15H12a1,1,0,0,0,1-1V4.17a1,1,0,0,0-.35-.77,1,1,0,0,0-.81-.22L.84,5A1,1,0,0,0,0,6v8A1,1,0,0,0,1,15ZM2,6.85l9-1.5V13H2Z" />
<path d="M30.84,0l-15,2.5a1,1,0,0,0-.84,1V14a1,1,0,0,0,1,1H31a1,1,0,0,0,1-1V1a1,1,0,0,0-.35-.76A1,1,0,0,0,30.84,0ZM30,13H17V4.35L30,2.18Z" />
<path d="M.84,27l11,1.83H12a1,1,0,0,0,1-1V18a1,1,0,0,0-1-1H1a1,1,0,0,0-1,1v8A1,1,0,0,0,.84,27ZM2,19h9v7.65l-9-1.5Z" />
<path d="M31,17H16a1,1,0,0,0-1,1V28.5a1,1,0,0,0,.84,1l15,2.5H31a1,1,0,0,0,.65-.24A1,1,0,0,0,32,31V18A1,1,0,0,0,31,17ZM30,29.82,17,27.65V19H30Z" />
<g xmlns="http://www.w3.org/2000/svg" data-name="&lt;Group&gt;" id="_Group_">
<polygon
data-name="&lt;Path&gt;"
id="_Path_"
points="12.5 10.5 22.5 10.5 22.5 1.5 12.5 2.69 12.5 10.5"
style="fill:none;stroke:#303c42;stroke-linecap:round;stroke-linejoin:round"
/><polygon
data-name="&lt;Path&gt;"
id="_Path_2"
points="9.5 10.5 9.5 3.05 1.5 4 1.5 10.5 9.5 10.5"
style="fill:none;stroke:#303c42;stroke-linecap:round;stroke-linejoin:round"
/><polygon
data-name="&lt;Path&gt;"
id="_Path_3"
points="9.5 13.5 1.5 13.5 1.5 20 9.5 20.95 9.5 13.5"
style="fill:none;stroke:#303c42;stroke-linecap:round;stroke-linejoin:round"
/><polygon
data-name="&lt;Path&gt;"
id="_Path_4"
points="12.5 13.5 12.5 21.31 22.5 22.5 22.5 13.5 12.5 13.5"
style="fill:none;stroke:#303c42;stroke-linecap:round;stroke-linejoin:round"
/>
</g>
</svg>
"""
end

View File

@@ -31,7 +31,7 @@ defmodule Web.Actors.Show do
|> assign_live_table("clients",
query_module: Clients.Client.Query,
sortable_fields: [],
hide_filters: [:client_or_actor_name],
hide_filters: [:client_or_actor_name, :presence, :verification],
callback: &handle_clients_update!/2
)
|> assign_live_table("flows",

View File

@@ -16,7 +16,9 @@ defmodule Web.Clients.Index do
query_module: Clients.Client.Query,
sortable_fields: [
{:clients, :name},
{:clients, :inserted_at}
{:clients, :last_seen_at},
{:clients, :inserted_at},
{:clients, :last_seen_user_agent}
],
hide_filters: [
:name
@@ -94,6 +96,9 @@ defmodule Web.Clients.Index do
<:col :let={client} label="status">
<.connection_status schema={client} />
</:col>
<:col :let={client} field={{:clients, :last_seen_at}} label="last started at">
<.relative_datetime datetime={client.last_seen_at} />
</:col>
<:col :let={client} field={{:clients, :inserted_at}} label="created at">
<.relative_datetime datetime={client.inserted_at} />
</:col>

View File

@@ -334,6 +334,40 @@ defmodule Web.Clients.Show do
</.live_table>
</:content>
</.section>
<.danger_zone :if={is_nil(@client.deleted_at)}>
<:action>
<.button_with_confirmation
id="delete_client"
style="danger"
icon="hero-trash-solid"
on_confirm="delete"
>
<:dialog_title>Confirm deletion of client</:dialog_title>
<:dialog_content>
<p>
Deleting the client doesn't remove it from the device; it will be re-created with the same
hardware attributes upon the next sign-in, but the verification status won't carry over.
</p>
<p class="mt-2">
To prevent the client owner from logging in again,
<.link navigate={~p"/#{@account}/actors/#{@client.actor_id}"} class={link_style()}>
disable the owning actor
</.link>
instead.
</p>
</:dialog_content>
<:dialog_confirm_button>
Delete Client
</:dialog_confirm_button>
<:dialog_cancel_button>
Cancel
</:dialog_cancel_button>
Delete Client
</.button_with_confirmation>
</:action>
</.danger_zone>
"""
end
@@ -411,6 +445,9 @@ defmodule Web.Clients.Show do
{:noreply, socket}
end
def handle_event(event, params, socket) when event in ["paginate", "order_by", "filter"],
do: handle_live_table_event(event, params, socket)
def handle_event("verify_client", _params, socket) do
{:ok, client} = Clients.verify_client(socket.assigns.client, socket.assigns.subject)
@@ -438,6 +475,14 @@ defmodule Web.Clients.Show do
{:noreply, assign(socket, :client, client)}
end
def handle_event(event, params, socket) when event in ["paginate", "order_by", "filter"],
do: handle_live_table_event(event, params, socket)
def handle_event("delete", _params, socket) do
{:ok, _client} = Clients.delete_client(socket.assigns.client, socket.assigns.subject)
socket =
socket
|> put_flash(:info, "Client was deleted.")
|> push_navigate(to: ~p"/#{socket.assigns.account}/clients")
{:noreply, socket}
end
end

View File

@@ -116,20 +116,15 @@ defmodule Web.Gateways.Show do
>
<:dialog_title>Confirm deletion of Gateway</:dialog_title>
<:dialog_content>
<p>
Are you sure you want to delete this Gateway?
</p>
<p class="mt-4 text-sm">
Deleting the gateway does not remove it's access token so it can be re-created again,
revoke the token on the
<.link
navigate={~p"/#{@account}/sites/#{@gateway.group}"}
class={["font-medium", link_style()]}
>
site
</.link>
page if you want to prevent the gateway from connecting to the portal.
</p>
Deleting the gateway does not remove it's access token so it can be re-created again,
revoke the token on the
<.link
navigate={~p"/#{@account}/sites/#{@gateway.group}"}
class={["font-medium", link_style()]}
>
site
</.link>
page if you want to prevent the gateway from connecting to the portal.
</:dialog_content>
<:dialog_confirm_button>
Delete Gateway
@@ -175,9 +170,9 @@ defmodule Web.Gateways.Show do
{:ok, _gateway} = Gateways.delete_gateway(socket.assigns.gateway, socket.assigns.subject)
socket =
push_navigate(socket,
to: ~p"/#{socket.assigns.account}/sites/#{socket.assigns.gateway.group}"
)
socket
|> put_flash(:info, "Gateway was deleted.")
|> push_navigate(to: ~p"/#{socket.assigns.account}/sites/#{socket.assigns.gateway.group}")
{:noreply, socket}
end

View File

@@ -468,6 +468,13 @@ defmodule Web.LiveTable do
assign(socket,
live_table_ids: [id] ++ (socket.assigns[:live_table_ids] || []),
query_module_by_table_id:
put_table_state(
socket,
id,
:query_module_by_table_id,
query_module
),
callback_by_table_id:
put_table_state(
socket,
@@ -496,6 +503,13 @@ defmodule Web.LiveTable do
:enforced_filters_by_table_id,
enforce_filters
),
order_by_table_id:
put_table_state(
socket,
id,
:order_by_table_id,
maybe_use_default_order_by(query_module)
),
limit_by_table_id: put_table_state(socket, id, :limit_by_table_id, limit)
)
end
@@ -559,6 +573,7 @@ defmodule Web.LiveTable do
end
defp handle_live_table_params(socket, params, id) do
query_module = Map.fetch!(socket.assigns.query_module_by_table_id, id)
enforced_filters = Map.fetch!(socket.assigns.enforced_filters_by_table_id, id)
sortable_fields = Map.fetch!(socket.assigns.sortable_fields_by_table_id, id)
limit = Map.fetch!(socket.assigns.limit_by_table_id, id)
@@ -589,7 +604,7 @@ defmodule Web.LiveTable do
socket,
id,
:order_by_table_id,
order_by
maybe_use_default_order_by(query_module, order_by)
),
list_opts_by_table_id:
put_table_state(
@@ -626,6 +641,20 @@ defmodule Web.LiveTable do
end
end
defp maybe_use_default_order_by(query_module, order_by \\ nil)
defp maybe_use_default_order_by(query_module, nil) do
if function_exported?(query_module, :cursor_fields, 0) do
query_module.cursor_fields() |> List.first()
else
[]
end
end
defp maybe_use_default_order_by(_query_module, order_by) do
order_by
end
defp reset_live_table_params(socket, id, message) do
{:noreply, socket} =
socket

View File

@@ -271,7 +271,6 @@ defmodule Web.ConnCase do
end
defp reject_tooltips(other) do
dbg(other)
other
end

View File

@@ -330,4 +330,24 @@ defmodule Web.Live.Clients.ShowTest do
assert table["verification"] =~ "Verified"
assert table["verification"] =~ "by"
end
test "allows deleting clients", %{
account: account,
client: client,
identity: identity,
conn: conn
} do
{:ok, lv, _html} =
conn
|> authorize_conn(identity)
|> live(~p"/#{account}/clients/#{client}")
lv
|> element("button[type=submit]", "Delete Client")
|> render_click()
assert_redirected(lv, ~p"/#{account}/clients")
assert Repo.get(Domain.Clients.Client, client.id).deleted_at
end
end