From f07d2932dcc04441bc16f01febe011131523a39a Mon Sep 17 00:00:00 2001 From: Jamil Date: Tue, 30 Sep 2025 09:23:30 -0400 Subject: [PATCH] feat(portal): show outdated clients (#10456) Clients more than one minor away from a particular won't be able to connect, so it would be useful to help admins recognize when this might be the case and encourage them to upgrade. We accomplish that with two small UX improvements in this PR: - In the outdated gateways email, we show them a count of clients that will no longer be able to connect to the new gateway version, linking them to a sorted view of the clients table - In the clients table, we add a new sortable `version` column to allow admins to see which clients are outdated Fixes #7727 Fixes #10385 --------- Signed-off-by: Jamil --- elixir/apps/domain/lib/domain/clients.ex | 9 +++ .../domain/lib/domain/clients/client/query.ex | 13 ++++ .../domain/lib/domain/component_versions.ex | 18 ++++- .../domain/lib/domain/mailer/notifications.ex | 11 ++- .../notifications/outdated_gateway.html.eex | 38 +++++++--- .../notifications/outdated_gateway.text.eex | 16 +++-- .../notifications/jobs/outdated_gateways.ex | 23 ++++-- .../apps/domain/test/domain/clients_test.exs | 70 +++++++++++++++++++ .../test/domain/mailer/notifications_test.exs | 28 +++++++- .../web/lib/web/live/clients/components.ex | 44 ++++++++++++ elixir/apps/web/lib/web/live/clients/index.ex | 9 ++- elixir/apps/web/lib/web/live/clients/show.ex | 9 ++- .../web/test/web/live/clients/index_test.exs | 2 + 13 files changed, 262 insertions(+), 28 deletions(-) diff --git a/elixir/apps/domain/lib/domain/clients.ex b/elixir/apps/domain/lib/domain/clients.ex index 1126cfe91..4b88a6569 100644 --- a/elixir/apps/domain/lib/domain/clients.ex +++ b/elixir/apps/domain/lib/domain/clients.ex @@ -39,6 +39,15 @@ defmodule Domain.Clients do |> Repo.aggregate(:count) end + def count_incompatible_for(account, gateway_version) do + Client.Query.not_deleted() + |> Client.Query.by_account_id(account.id) + |> Client.Query.by_last_seen_within(1, "week") + |> Client.Query.by_incompatible_for(gateway_version) + |> Client.Query.only_for_active_actors() + |> Repo.aggregate(:count) + end + def fetch_client_by_id(id, preload: :identity) do Client.Query.not_deleted() |> Client.Query.by_id(id) diff --git a/elixir/apps/domain/lib/domain/clients/client/query.ex b/elixir/apps/domain/lib/domain/clients/client/query.ex index 3a7227e7f..f5de0782b 100644 --- a/elixir/apps/domain/lib/domain/clients/client/query.ex +++ b/elixir/apps/domain/lib/domain/clients/client/query.ex @@ -54,6 +54,19 @@ defmodule Domain.Clients.Client.Query do }) end + def by_incompatible_for(queryable, gateway_version) do + %{major: g_major, minor: g_minor} = Version.parse!(gateway_version) + + # Incompatible if majors differ or client is two or more minors behind + where( + queryable, + [clients: clients], + fragment("split_part(?, '.', 1)::int", clients.last_seen_version) < ^g_major or + (fragment("split_part(?, '.', 1)::int", clients.last_seen_version) == ^g_major and + fragment("split_part(?, '.', 2)::int", clients.last_seen_version) <= ^(g_minor - 2)) + ) + end + def returning_not_deleted(queryable) do select(queryable, [clients: clients], clients) end diff --git a/elixir/apps/domain/lib/domain/component_versions.ex b/elixir/apps/domain/lib/domain/component_versions.ex index 73ef7b58d..24ae84210 100644 --- a/elixir/apps/domain/lib/domain/component_versions.ex +++ b/elixir/apps/domain/lib/domain/component_versions.ex @@ -1,5 +1,5 @@ defmodule Domain.ComponentVersions do - alias Domain.ComponentVersions + alias Domain.{Actors.Actor, Clients.Client, ComponentVersions} use Supervisor require Logger @@ -34,6 +34,12 @@ defmodule Domain.ComponentVersions do ComponentVersions.component_version(:gateway) end + def client_version(%Client{} = client) do + client + |> get_component_type() + |> component_version() + end + def component_version(component) do Domain.Config.get_env(:domain, ComponentVersions, []) |> Keyword.get(:versions, []) @@ -85,4 +91,14 @@ defmodule Domain.ComponentVersions do |> Keyword.fetch!(:versions) end end + + defp get_component_type(%Client{last_seen_user_agent: "Mac OS" <> _rest}), do: :apple + defp get_component_type(%Client{last_seen_user_agent: "iOS" <> _rest}), do: :apple + + defp get_component_type(%Client{last_seen_user_agent: "Android" <> _rest}), + do: :android + + defp get_component_type(%Client{actor: %Actor{type: :service_account}}), do: :headless + + defp get_component_type(_), do: :gui end diff --git a/elixir/apps/domain/lib/domain/mailer/notifications.ex b/elixir/apps/domain/lib/domain/mailer/notifications.ex index bd7773bca..604321a91 100644 --- a/elixir/apps/domain/lib/domain/mailer/notifications.ex +++ b/elixir/apps/domain/lib/domain/mailer/notifications.ex @@ -2,17 +2,26 @@ defmodule Domain.Mailer.Notifications do import Swoosh.Email import Domain.Mailer import Phoenix.Template, only: [embed_templates: 2] + import Phoenix.VerifiedRoutes + + @endpoint Web.Endpoint + @router Web.Router embed_templates "notifications/*.html", suffix: "_html" embed_templates "notifications/*.text", suffix: "_text" - def outdated_gateway_email(account, gateways, email) do + def outdated_gateway_email(account, gateways, incompatible_client_count, email) do + outdated_clients_url = + url(~p"/#{account.id}/clients?#{[clients_order_by: "clients:asc:last_seen_version"]}") + default_email() |> subject("Firezone Gateway Upgrade Available") |> to(email) |> render_body(__MODULE__, :outdated_gateway, account: account, gateways: gateways, + outdated_clients_url: outdated_clients_url, + incompatible_client_count: incompatible_client_count, latest_version: Domain.ComponentVersions.gateway_version() ) end diff --git a/elixir/apps/domain/lib/domain/mailer/notifications/outdated_gateway.html.eex b/elixir/apps/domain/lib/domain/mailer/notifications/outdated_gateway.html.eex index 21ec97218..e1eb4874f 100644 --- a/elixir/apps/domain/lib/domain/mailer/notifications/outdated_gateway.html.eex +++ b/elixir/apps/domain/lib/domain/mailer/notifications/outdated_gateway.html.eex @@ -19,7 +19,7 @@ td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: "Segoe UI", sans-serif; mso-line-height-rule: exactly;} - Firezone Gateway Update Available + Firezone Gateway Upgrade Available