feat(portal): Show clients peek on actors index (#7100)

We will show up to 5 recently started client icons and a status for them
as a green dot badge (no dot when it's offline to keep things simple).
Additional details are available on hover.

<img width="1415" alt="1"
src="https://github.com/user-attachments/assets/1d48d08b-f024-4016-837a-3a2ac9a34718">
<img width="1413" alt="2"
src="https://github.com/user-attachments/assets/101ff122-26e2-4282-ae1d-073b4eba9c56">

I also extended the `Clients` table on "Actor" view page to match the
"Clients" index view.

Also closes #7096
This commit is contained in:
Andrew Dryga
2024-10-28 16:06:22 -06:00
committed by GitHub
parent 046b9e0cd4
commit f296dc5ad2
15 changed files with 444 additions and 45 deletions

View File

@@ -121,6 +121,33 @@ defmodule Domain.Actors do
end
end
def peek_actor_clients(actors, limit, %Auth.Subject{} = subject) do
with :ok <-
Auth.ensure_has_permissions(subject, Clients.Authorizer.manage_clients_permission()) do
ids = actors |> Enum.map(& &1.id) |> Enum.uniq()
{:ok, peek} =
Actor.Query.not_deleted()
|> Actor.Query.by_id({:in, ids})
|> Actor.Query.preload_few_clients_for_each_actor(limit)
|> Authorizer.for_subject(subject)
|> Repo.peek(actors)
group_by_ids =
Enum.flat_map(peek, fn {_id, %{items: items}} -> items end)
|> Clients.preload_clients_presence()
|> Enum.map(&{&1.id, &1})
|> Enum.into(%{})
peek =
for {id, %{items: items} = map} <- peek, into: %{} do
{id, %{map | items: Enum.map(items, &Map.fetch!(group_by_ids, &1.id))}}
end
{:ok, peek}
end
end
def sync_provider_groups(%Auth.Provider{} = provider, attrs_list) do
Group.Sync.sync_provider_groups(provider, attrs_list)
end

View File

@@ -66,6 +66,37 @@ defmodule Domain.Actors.Actor.Query do
# Preloads
def preload_few_clients_for_each_actor(queryable, limit) do
queryable
|> with_joined_clients(limit)
|> with_joined_client_counts()
|> select([actors: actors, clients: clients, client_counts: client_counts], %{
id: actors.id,
count: client_counts.count,
item: clients
})
end
def with_joined_clients(queryable, limit) do
subquery =
Domain.Clients.Client.Query.not_deleted()
|> where([clients: clients], clients.actor_id == parent_as(:actors).id)
|> order_by([clients: clients], desc: clients.last_seen_at)
|> limit(^limit)
join(queryable, :cross_lateral, [actors: actors], clients in subquery(subquery), as: :clients)
end
def with_joined_client_counts(queryable) do
subquery =
Domain.Clients.Client.Query.count_clients_by_actor_id()
|> where([clients: clients], clients.actor_id == parent_as(:actors).id)
join(queryable, :cross_lateral, [actors: actors], client_counts in subquery(subquery),
as: :client_counts
)
end
def preload_few_groups_for_each_actor(queryable, limit) do
queryable
|> with_joined_memberships(limit)
@@ -170,7 +201,8 @@ defmodule Domain.Actors.Actor.Query do
@impl Domain.Repo.Query
def preloads,
do: [
last_seen_at: &Domain.Actors.preload_last_seen_at/1
last_seen_at: &Domain.Actors.preload_last_seen_at/1,
clients: {nil, Domain.Clients.Client.Query.preloads()}
]
@impl Domain.Repo.Query

View File

@@ -48,6 +48,15 @@ defmodule Domain.Clients.Client.Query do
|> distinct(true)
end
def count_clients_by_actor_id(queryable \\ not_deleted()) do
queryable
|> group_by([clients: clients], clients.actor_id)
|> select([clients: clients], %{
actor_id: clients.actor_id,
count: count(clients.id)
})
end
def returning_not_deleted(queryable) do
select(queryable, [clients: clients], clients)
end

View File

@@ -66,8 +66,8 @@ defmodule Domain.Repo.Paginator do
def query(queryable, paginator_opts) do
queryable
|> order_by_cursor_fields(paginator_opts)
|> maybe_query_page(paginator_opts)
|> order_by_cursor_fields(paginator_opts)
|> limit_page_size(paginator_opts)
end

View File

@@ -221,14 +221,58 @@ for actor <- other_actors do
provider_identifier_confirmation: email
})
identity
|> Ecto.Changeset.change(
created_by: :provider,
provider_id: oidc_provider.id,
provider_identifier: email,
provider_state: %{"claims" => %{"email" => email, "group" => "users"}}
)
|> Repo.update!()
identity =
identity
|> Ecto.Changeset.change(
created_by: :provider,
provider_id: oidc_provider.id,
provider_identifier: email,
provider_state: %{"claims" => %{"email" => email, "group" => "users"}}
)
|> Repo.update!()
context = %Auth.Context{
type: :browser,
user_agent: "Windows/10.0.22631 seeds/1",
remote_ip: {172, 28, 0, 100},
remote_ip_location_region: "UA",
remote_ip_location_city: "Kyiv",
remote_ip_location_lat: 50.4333,
remote_ip_location_lon: 30.5167
}
{:ok, token} =
Auth.create_token(identity, context, "n", nil)
{:ok, subject} = Auth.build_subject(token, context)
count = Enum.random([1, 1, 1, 1, 1, 2, 2, 2, 3, 3, 240])
for i <- 0..count do
user_agent =
Enum.random([
"iOS/12.7 (iPhone) connlib/1.5.0",
"Android/14 connlib/1.4.1",
"Windows/10.0.22631 connlib/1.3.412",
"Ubuntu/22.4.0 connlib/1.2.2"
])
client_name = String.split(user_agent, "/") |> List.first()
{:ok, _client} =
Domain.Clients.upsert_client(
%{
name: "My #{client_name} #{i}",
external_id: Ecto.UUID.generate(),
public_key: :crypto.strong_rand_bytes(32) |> Base.encode64(),
identifier_for_vendor: Ecto.UUID.generate()
},
%{
subject
| context: %{subject.context | user_agent: user_agent}
}
)
end
end
# Other Account Users
@@ -432,7 +476,7 @@ end
{:ok, finance_group} = Actors.create_group(%{name: "Finance", type: :static}, admin_subject)
{:ok, synced_group} =
Actors.create_group(%{name: "Synced Group with long name", type: :static}, admin_subject)
Actors.create_group(%{name: "Group:Synced Group with long name", type: :static}, admin_subject)
for group <- [eng_group, finance_group, synced_group] do
IO.puts(" Name: #{group.name} ID: #{group.id}")
@@ -476,6 +520,25 @@ oidc_provider
|> Ecto.Changeset.change(last_synced_at: DateTime.utc_now())
|> Repo.update!()
for name <- [
"Group:gcp-logging-viewers",
"Group:gcp-security-admins",
"Group:gcp-organization-admins",
"OU:Admins",
"OU:Product",
"Group:Engineering",
"Group:gcp-developers"
] do
{:ok, group} = Actors.create_group(%{name: name, type: :static}, admin_subject)
group
|> Repo.preload(:memberships)
|> Actors.update_group(
%{memberships: [%{actor_id: admin_subject.actor.id}]},
admin_subject
)
end
IO.puts("")
{:ok, global_relay_group} =

View File

@@ -2,6 +2,7 @@ defmodule Domain.ActorsTest do
use Domain.DataCase, async: true
import Domain.Actors
alias Domain.Auth
alias Domain.Clients
alias Domain.Actors
describe "fetch_groups_count_grouped_by_provider_id/1" do
@@ -585,6 +586,142 @@ defmodule Domain.ActorsTest do
end
end
describe "peek_actor_clients/3" do
setup do
account = Fixtures.Accounts.create_account()
actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account)
identity = Fixtures.Auth.create_identity(account: account, actor: actor)
subject = Fixtures.Auth.create_subject(identity: identity)
%{
account: account,
actor: actor,
identity: identity,
subject: subject
}
end
test "returns count of clients per actor and first 3 clients", %{
account: account,
subject: subject
} do
actor1 = Fixtures.Actors.create_actor(account: account)
Fixtures.Clients.create_client(account: account, actor: actor1)
Fixtures.Clients.create_client(account: account, actor: actor1)
Fixtures.Clients.create_client(account: account, actor: actor1)
Fixtures.Clients.create_client(account: account, actor: actor1)
actor2 = Fixtures.Actors.create_actor(account: account)
assert {:ok, peek} = peek_actor_clients([actor1, actor2], 3, subject)
assert length(Map.keys(peek)) == 2
assert peek[actor1.id].count == 4
assert length(peek[actor1.id].items) == 3
assert [%Clients.Client{} | _] = peek[actor1.id].items
assert peek[actor2.id].count == 0
assert Enum.empty?(peek[actor2.id].items)
end
test "preloads client presence", %{
account: account,
subject: subject
} do
actor = Fixtures.Actors.create_actor(account: account)
client = Fixtures.Clients.create_client(account: account, actor: actor)
Domain.Clients.connect_client(client)
assert {:ok, peek} = peek_actor_clients([actor], 3, subject)
assert [%Clients.Client{} = client] = peek[actor.id].items
assert client.online?
end
test "returns count of clients per actor and first LIMIT clients", %{
account: account,
subject: subject
} do
actor = Fixtures.Actors.create_actor(account: account)
Fixtures.Clients.create_client(account: account, actor: actor)
Fixtures.Clients.create_client(account: account, actor: actor)
other_actor = Fixtures.Actors.create_actor(account: account)
Fixtures.Clients.create_client(account: account, actor: other_actor)
assert {:ok, peek} = peek_actor_clients([actor], 1, subject)
assert length(peek[actor.id].items) == 1
assert Enum.count(peek) == 1
end
test "ignores deleted clients", %{
account: account,
subject: subject
} do
actor = Fixtures.Actors.create_actor(account: account)
Fixtures.Clients.create_client(account: account, actor: actor)
|> Fixtures.Clients.delete_client()
assert {:ok, peek} = peek_actor_clients([actor], 3, subject)
assert peek[actor.id].count == 0
assert Enum.empty?(peek[actor.id].items)
end
test "ignores other clients", %{
account: account,
subject: subject
} do
Fixtures.Clients.create_client(account: account)
Fixtures.Clients.create_client(account: account)
actor = Fixtures.Actors.create_actor(account: account)
assert {:ok, peek} = peek_actor_clients([actor], 1, subject)
assert peek[actor.id].count == 0
assert Enum.empty?(peek[actor.id].items)
end
test "returns empty map on empty actors", %{subject: subject} do
assert peek_actor_clients([], 1, subject) == {:ok, %{}}
end
test "returns empty map on empty clients", %{account: account, subject: subject} do
actor = Fixtures.Actors.create_actor(account: account)
assert {:ok, peek} = peek_actor_clients([actor], 3, subject)
assert length(Map.keys(peek)) == 1
assert peek[actor.id].count == 0
assert Enum.empty?(peek[actor.id].items)
end
test "does not allow peeking into other accounts", %{
subject: subject
} do
other_account = Fixtures.Accounts.create_account()
actor = Fixtures.Actors.create_actor(account: other_account)
Fixtures.Clients.create_client(account: other_account, actor: actor)
assert {:ok, peek} = peek_actor_clients([actor], 3, subject)
assert Map.has_key?(peek, actor.id)
assert peek[actor.id].count == 0
assert Enum.empty?(peek[actor.id].items)
end
test "returns error when subject has no permission to manage groups", %{
subject: subject
} do
subject = Fixtures.Auth.remove_permissions(subject)
assert peek_actor_clients([], 3, subject) ==
{:error,
{:unauthorized,
reason: :missing_permissions,
missing_permissions: [Domain.Clients.Authorizer.manage_clients_permission()]}}
end
end
describe "sync_provider_groups/2" do
setup do
account = Fixtures.Accounts.create_account()

View File

@@ -967,13 +967,14 @@ defmodule Web.CoreComponents do
attr :datetime, DateTime, default: nil
attr :relative_to, DateTime, required: false
attr :negative_class, :string, default: ""
attr :popover, :boolean, default: true
def relative_datetime(assigns) do
assigns =
assign_new(assigns, :relative_to, fn -> DateTime.utc_now() end)
~H"""
<.popover :if={not is_nil(@datetime)}>
<.popover :if={not is_nil(@datetime) and @popover}>
<:target>
<span class={[
"underline underline-offset-2 decoration-1 decoration-dotted",
@@ -987,6 +988,10 @@ defmodule Web.CoreComponents do
<%= @datetime %>
</:content>
</.popover>
<span :if={not @popover}>
<%= Cldr.DateTime.Relative.to_string!(@datetime, Web.CLDR, relative_to: @relative_to)
|> String.capitalize() %>
</span>
<span :if={is_nil(@datetime)}>
Never
</span>
@@ -1056,6 +1061,17 @@ defmodule Web.CoreComponents do
"""
end
@doc """
Renders online or offline status using an `online?` field of the schema.
"""
attr :schema, :any, required: true
def online_icon(assigns) do
~H"""
<span :if={@schema.online?} class="inline-flex rounded-full h-2.5 w-2.5 bg-green-500"></span>
"""
end
attr :navigate, :string, required: true
attr :connected?, :boolean, required: true
attr :type, :string, required: true
@@ -1459,10 +1475,11 @@ defmodule Web.CoreComponents do
"""
attr :color, :string, default: "info"
attr :class, :string, default: nil
def ping_icon(assigns) do
~H"""
<span class="relative flex h-2.5 w-2.5">
<span class={["relative flex h-2.5 w-2.5", @class]}>
<span class={~w[
animate-ping absolute inline-flex
h-full w-full rounded-full opacity-50

View File

@@ -102,14 +102,18 @@ defmodule Web.NavigationComponents do
def sidebar(assigns) do
~H"""
<aside class={~w[
fixed top-0 left-0 z-40
w-64 h-screen
pt-14 pb-8
transition-transform -translate-x-full
bg-white border-r border-neutral-200
lg:translate-x-0
]} aria-label="Sidenav" id="drawer-navigation">
<aside
class={[
"fixed top-0 left-0 z-40 lg:z-10",
"w-64 h-screen",
"pt-14 pb-8",
"transition-transform -translate-x-full",
"bg-white border-r border-neutral-200",
"lg:translate-x-0"
]}
aria-label="Sidenav"
id="drawer-navigation"
>
<div class="overflow-y-auto py-1 px-2 h-full bg-white">
<ul>
<%= render_slot(@inner_block) %>

View File

@@ -1,6 +1,7 @@
defmodule Web.Actors.Index do
use Web, :live_view
import Web.Actors.Components
import Web.Clients.Components
alias Domain.Actors
def mount(_params, _session, socket) do
@@ -9,7 +10,6 @@ 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}
@@ -26,15 +26,21 @@ defmodule Web.Actors.Index do
end
def handle_actors_update!(socket, list_opts) do
list_opts = Keyword.put(list_opts, :preload, [:last_seen_at, identities: :provider])
list_opts =
Keyword.put(list_opts, :preload, [
:last_seen_at,
identities: :provider
])
with {:ok, actors, metadata} <- Actors.list_actors(socket.assigns.subject, list_opts),
{:ok, actor_groups} <- Actors.peek_actor_groups(actors, 3, socket.assigns.subject) do
{:ok, actor_groups} <- Actors.peek_actor_groups(actors, 3, socket.assigns.subject),
{:ok, actor_clients} <- Actors.peek_actor_clients(actors, 5, socket.assigns.subject) do
{:ok,
assign(socket,
actors: actors,
actors_metadata: metadata,
actor_groups: actor_groups
actor_groups: actor_groups,
actor_clients: actor_clients
)}
end
end
@@ -73,7 +79,7 @@ defmodule Web.Actors.Index do
ordered_by={@order_by_table_id["actors"]}
metadata={@actors_metadata}
>
<:col :let={actor} field={{:actors, :name}} label="name">
<:col :let={actor} field={{:actors, :name}} label="name" class="w-2/12">
<span class="block truncate" title={actor.name}>
<.actor_name_and_role account={@account} actor={actor} />
</span>
@@ -89,20 +95,60 @@ defmodule Web.Actors.Index do
</div>
</:col>
<:col :let={actor} label="groups" class="w-4/12">
<.peek peek={@actor_groups[actor.id]}>
<:col :let={actor} label="groups" class="w-1/12">
<.popover placement="right">
<:target>
<.link
navigate={~p"/#{@account}/actors/#{actor}?#groups"}
class={[
"hover:underline hover:decoration-line",
"underline underline-offset-2 decoration-1 decoration-dotted"
]}
>
<%= @actor_groups[actor.id].count %>
</.link>
</:target>
<:content>
<.peek peek={@actor_groups[actor.id]}>
<:empty>
None
</:empty>
<:item :let={group}>
<div class="flex flex-wrap gap-y-2 mr-2">
<.group account={@account} group={group} />
</div>
</:item>
<:tail :let={count}>
<span class="inline-block whitespace-nowrap">
and <%= count %> more.
</span>
</:tail>
</.peek>
</:content>
</.popover>
</:col>
<:col :let={actor} label="clients" class="w-2/12">
<.peek peek={@actor_clients[actor.id]}>
<:empty>
None
</:empty>
<:item :let={group}>
<div class="flex flex-wrap gap-y-2 mr-2">
<.group account={@account} group={group} />
<:item :let={client}>
<div class="mr-2">
<.client_as_icon client={client} />
<div class="relative">
<div class="absolute -inset-y-2.5 -right-1">
<.online_icon schema={client} />
</div>
</div>
</div>
</:item>
<:tail :let={count}>
<span class="inline-block whitespace-nowrap">
<span class={["inline-block whitespace-nowrap"]}>
and <%= count %> more.
</span>
</:tail>

View File

@@ -1,6 +1,7 @@
defmodule Web.Actors.Show do
use Web, :live_view
import Web.Actors.Components
import Web.Clients.Components
alias Domain.{Accounts, Auth, Tokens, Flows, Clients}
alias Domain.Actors
@@ -467,16 +468,42 @@ defmodule Web.Actors.Show do
ordered_by={@order_by_table_id["clients"]}
metadata={@clients_metadata}
>
<:col :let={client} label="name">
<.link navigate={~p"/#{@account}/clients/#{client.id}"} class={[link_style()]}>
<%= client.name %>
</.link>
<:col :let={client} class="w-8">
<.popover placement="right">
<:target>
<.client_os_icon client={client} />
</:target>
<:content>
<.client_os_name_and_version client={client} />
</:content>
</.popover>
</:col>
<:col :let={client} field={{:clients, :name}} label="name">
<div class="flex items-center space-x-1">
<.link navigate={~p"/#{@account}/clients/#{client.id}"} class={[link_style()]}>
<%= client.name %>
</.link>
<.icon
:if={not is_nil(client.verified_at)}
name="hero-shield-check"
class="w-4 h-4"
title="Device attributes of this client are manually verified"
/>
</div>
</:col>
<:col :let={client} label="status">
<.connection_status schema={client} />
</:col>
<:col :let={client} field={{:clients, :last_seen_at}} label="last started">
<.relative_datetime datetime={client.last_seen_at} />
</:col>
<:col :let={client} field={{:clients, :inserted_at}} label="created">
<.relative_datetime datetime={client.inserted_at} />
</:col>
<:empty>
<div class="text-center text-neutral-500 p-4">No clients to display.</div>
<div class="text-center text-neutral-500 p-4">
Actor has not signed in from any Client.
</div>
</:empty>
</.live_table>
</:content>

View File

@@ -1,5 +1,6 @@
defmodule Web.Clients.Components do
use Web, :component_library
import Web.CoreComponents
def client_os(assigns) do
~H"""
@@ -28,6 +29,31 @@ defmodule Web.Clients.Components do
"""
end
def client_as_icon(assigns) do
~H"""
<.popover placement="right">
<:target>
<.client_os_icon client={@client} />
</:target>
<:content>
<div>
<%= @client.name %>
</div>
<div>
<.client_os_name_and_version client={@client} />
</div>
<div>
<span>Last started:</span>
<.relative_datetime datetime={@client.last_seen_at} popover={false} />
</div>
<div>
<.connection_status schema={@client} />
</div>
</:content>
</.popover>
"""
end
def client_os_icon_name("Windows/" <> _), do: "os-windows"
def client_os_icon_name("Mac OS/" <> _), do: "os-macos"
def client_os_icon_name("iOS/" <> _), do: "os-ios"

View File

@@ -111,7 +111,7 @@ defmodule Web.Clients.Index do
</:col>
<:empty>
<div class="text-center text-neutral-500 p-4">
No clients to display. Clients are created automatically when a user connects to a resource.
No Actors have signed in from any Client.
</div>
</:empty>
</.live_table>

View File

@@ -59,6 +59,8 @@ defmodule Web.Live.Actors.IndexTest do
} do
admin_actor = Fixtures.Actors.create_actor(account: account, type: :account_admin_user)
Fixtures.Actors.create_membership(account: account, actor: admin_actor)
client = Fixtures.Clients.create_client(account: account, actor: admin_actor)
Domain.Clients.connect_client(client)
admin_actor = Repo.preload(admin_actor, identities: [:provider], groups: [])
user_actor = Fixtures.Actors.create_actor(account: account, type: :account_user)
@@ -84,14 +86,13 @@ defmodule Web.Live.Actors.IndexTest do
|> render()
|> table_to_map()
for {actor, name} <- [
{admin_actor, "#{admin_actor.name} (admin)"},
{user_actor, user_actor.name},
{service_account_actor, "#{service_account_actor.name} (service account)"}
for {actor, name, clients} <- [
{admin_actor, "#{admin_actor.name} (admin)", [client]},
{user_actor, user_actor.name, []},
{service_account_actor, "#{service_account_actor.name} (service account)", []}
] do
with_table_row(rows, "name", name, fn row ->
for identity <- actor.identities do
assert row["identifiers"] =~ identity.provider.name
assert row["identifiers"] =~ identity.provider_identifier
end
@@ -99,7 +100,14 @@ defmodule Web.Live.Actors.IndexTest do
assert row["groups"] =~ group.name
end
assert row["last signed in"] == "Never"
for client <- clients do
assert row["clients"] =~ client.name
assert row["clients"] =~ "Online"
assert row["clients"] =~ "Apple"
assert row["clients"] =~ "iOS 12.5"
end
assert row["last signed in"]
end)
end
end

View File

@@ -70,8 +70,11 @@ defmodule Web.Live.Actors.ShowTest do
|> render()
|> table_to_map()
assert row[""] =~ "Apple iOS"
assert row["name"] == client.name
assert row["status"] == "Offline"
assert row["last started"]
assert row["created"]
end
test "updates clients table using presence events", %{

View File

@@ -48,7 +48,7 @@ defmodule Web.Live.Clients.IndexTest do
|> authorize_conn(identity)
|> live(~p"/#{account}/clients")
assert html =~ "No clients to display"
assert html =~ "No Actors have signed in from any Client"
end
test "renders clients table", %{