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 <jamilbk@users.noreply.github.com>
This commit is contained in:
Jamil
2025-09-30 09:23:30 -04:00
committed by GitHub
parent 1baf1f3a6e
commit f07d2932dc
13 changed files with 262 additions and 28 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;}
</style>
<![endif]-->
<title>Firezone Gateway Update Available</title>
<title>Firezone Gateway Upgrade Available</title>
<style>
.hover-important-text-decoration-underline:hover {
text-decoration: underline !important
@@ -48,9 +48,9 @@
</head>
<body style="margin: 0; width: 100%; background-color: #f6f6f6; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word">
<div style="display: none">
Firezone Gateway Update Available
Firezone Gateway Upgrade Available
</div>
<div role="article" aria-roledescription="email" aria-label="Firezone Gateway Update Available" lang="en">
<div role="article" aria-roledescription="email" aria-label="Firezone Gateway Upgrade Available" lang="en">
<div class="sm-px-4" style="background-color: #f6f6f6; font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif">
<table align="center" cellpadding="0" cellspacing="0" role="none">
<tr>
@@ -66,23 +66,27 @@
<tr>
<td class="sm-px-6" style="border-radius: 4px; background-color: #ffffff; padding: 48px; font-size: 16px; color: #4f4f4f; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05)">
<h1 class="sm-leading-8" style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #000000">
Gateway Update Available!
Gateway Upgrade Available!
</h1>
<p style="margin: 0; line-height: 24px">
The latest Firezone Gateway release is: <%= @latest_version %>
The latest Firezone Gateway release is: <span style="font-weight: 600"><%= @latest_version %></span>
</p>
<p style="margin: 0; line-height: 24px">
<a href="https://www.firezone.dev/changelog?tab=gateway" target="_blank">See what's changed in this release</a>.
</p>
<div role="separator" style="line-height: 16px">&zwj;</div>
<p style="margin: 0; line-height: 24px;">
The following list of Gateways in your Firezone Account can be updated.
The following list of Gateways in your Firezone Account can be upgraded.
</p>
<div role="separator" style="background-color: #d1d1d1; height: 1px; line-height: 1px; margin: 32px 0">&zwj;</div>
<p style="margin: 0; line-height: 24px;">
<p style="margin: 0 0 16 0; line-height: 24px;">
<span style="margin-bottom: 32px; font-weight: 500; text-decoration-line: underline; text-underline-offset: 2px">Outdated Gateways</span>
</p>
<table class="rtl-text-right" style="width: 100%; text-align: left; font-size: 14px; color: #6d6d6d" cellpadding="0" cellspacing="0" role="none">
<tr style="border-bottom-width: 1px; background-color: #f9f9f9">
<th scope="col" style="white-space: nowrap; padding-left: 24px; padding-right: 24px; font-weight: 600; text-align: left; color: #3d3d3d">Gateway Name</th>
<th scope="col" style="padding: 4px 24px; font-weight: 600; text-align: left; color: #3d3d3d">Last Seen Version</th>
</tr>
<%= for gateway <- @gateways do %>
<tr style="border-bottom-width: 1px; background-color: #ffffff">
<th scope="row" style="white-space: nowrap; padding-left: 24px; padding-right: 24px; font-weight: 500; color: #3d3d3d"><%= gateway.name %></th>
@@ -92,11 +96,25 @@
</table>
<p></p>
<div role="separator" style="background-color: #d1d1d1; height: 1px; line-height: 1px; margin: 32px 0;">&zwj;</div>
<%= if @incompatible_client_count > 0 do %>
<p style="margin: 0; line-height: 24px;">
We recommend updating the Gateways at your earliest convenience. Help on how to update Gateways can be found
<span style="font-weight: 600; color: #6b6b6b">WARNING:</span>
<span style="font-weight: 600"><a href="<%= @outdated_clients_url %>" target="_blank"><%= @incompatible_client_count %> recently connected client(s)</a> are not compatible</span> with the latest Gateway version. We recommend upgrading these clients before upgrading the Gateways to prevent loss of connectivity.
<a href="<%= @outdated_clients_url %>" target="_blank">See all outdated clients</a> in your account.
</p>
<p style="margin: 0; line-height: 24px; margin-top: 16px;">
<a href="https://www.firezone.dev/kb/administer/upgrading#version-compatibility">Read more</a> about version compatibility in Firezone.
</p>
<p style="margin: 0; line-height: 24px; margin-top: 16px;">
Read more about upgrading in our <a href="https://www.firezone.dev/kb/administer/upgrading">upgrade guide</a>.
</p>
<% else %>
<p style="margin: 0; line-height: 24px;">
We recommend updating the Gateways at your earliest convenience. Help on how to upgrade Gateways can be found
in our <a href="https://www.firezone.dev/kb/administer/upgrading">Firezone Gateway Upgrade Guide</a>.
<br>
<br>
</p>
<% end %>
<p style="margin: 0; margin-top: 16px; line-height: 24px">
Thanks, <br>The Firezone Team
</p>
<div role="separator" style="line-height: 16px">&zwj;</div>

View File

@@ -1,17 +1,25 @@
Gateway Update Available!
Gateway Upgrade Available!
The latest Firezone Gateway release is: <%= @latest_version %>
See what's changed: https://www.firezone.dev/changelog?tab=gateway
The following list of Gateways in your Firezone Account can be updated:
The following list of Gateways in your Firezone Account can be upgraded:
<%= for gateway <- @gateways do %>
<%= "- #{gateway.name} -> #{gateway.last_seen_version}" %>
<% end %>
We recommend updating the Gateways at your earliest convenience.
<%= if @incompatible_client_count > 0 do %>
Warning: <%= @incompatible_client_count %> recently connected client(s) are not compatible with the latest Gateway version. We recommend upgrading these clients before upgrading the Gateways to prevent loss of connectivity.
Help on how to update Gateways can be found in our Firezone Documentation:
See all outdated clients in your account: <%= @outdated_clients_url %>
Read more about version compatibility: https://www.firezone.dev/kb/administer/upgrading#version-compatibility
<% else %>
We recommend upgrading the Gateways at your earliest convenience.
<% end %>
Help on how to upgrade Gateways can be found in our Firezone Documentation:
https://www.firezone.dev/kb/administer/upgrading
Thanks,

View File

@@ -8,7 +8,7 @@ defmodule Domain.Notifications.Jobs.OutdatedGateways do
require OpenTelemetry.Tracer
alias Domain.Actors
alias Domain.{Accounts, Gateways, Mailer}
alias Domain.{Accounts, Clients, Gateways, Mailer}
@impl true
if Mix.env() == :prod do
@@ -24,11 +24,15 @@ defmodule Domain.Notifications.Jobs.OutdatedGateways do
end
defp run_check do
latest_version = Domain.ComponentVersions.gateway_version()
Accounts.all_accounts_pending_notification!()
|> Enum.each(fn account ->
incompatible_client_count = Clients.count_incompatible_for(account, latest_version)
all_online_gateways_for_account(account)
|> Enum.filter(&Gateways.gateway_outdated?/1)
|> send_notifications(account)
|> send_notifications(account, incompatible_client_count)
end)
end
@@ -42,14 +46,14 @@ defmodule Domain.Notifications.Jobs.OutdatedGateways do
|> Enum.flat_map(&Map.get(gateways_by_id, &1))
end
defp send_notifications([], _account) do
defp send_notifications([], _account, _incompatible_client_count) do
Logger.debug("No outdated gateways for account")
end
defp send_notifications(gateways, account) do
defp send_notifications(gateways, account, incompatible_client_count) do
Domain.Actors.all_admins_for_account!(account, preload: :identities)
|> Enum.flat_map(&list_emails_for_actor/1)
|> Enum.each(&send_email(account, gateways, &1))
|> Enum.each(&send_email(account, gateways, incompatible_client_count, &1))
Domain.Accounts.update_account(account, %{
config: %{
@@ -68,8 +72,13 @@ defmodule Domain.Notifications.Jobs.OutdatedGateways do
|> Enum.uniq()
end
defp send_email(account, gateways, email) do
Mailer.Notifications.outdated_gateway_email(account, gateways, email)
defp send_email(account, gateways, incompatible_client_count, email) do
Mailer.Notifications.outdated_gateway_email(
account,
gateways,
incompatible_client_count,
email
)
|> Mailer.deliver_with_rate_limit()
end

View File

@@ -28,6 +28,76 @@ defmodule Domain.ClientsTest do
}
end
describe "count_incompatible_for/2" do
test "returns 0 when there are no clients", %{account: account} do
assert count_incompatible_for(account, "1.2.3") == 0
end
test "return 0 when clients are the same version", %{
account: account,
unprivileged_subject: subject
} do
subject = %{
subject
| context: %Domain.Auth.Context{subject.context | user_agent: "iOS/12.5 connlib/1.2.3"}
}
Fixtures.Clients.create_client(account: account, subject: subject)
assert count_incompatible_for(account, "1.2.3") == 0
end
test "returns 0 when client is one minor behind", %{
account: account,
unprivileged_subject: subject
} do
subject = %{
subject
| context: %Domain.Auth.Context{subject.context | user_agent: "iOS/12.5 connlib/1.1.2"}
}
Fixtures.Clients.create_client(account: account, subject: subject)
assert count_incompatible_for(account, "1.2.3") == 0
end
test "returns 0 for clients that haven't been seen in over a week", %{account: account} do
one_month_ago = DateTime.utc_now() |> DateTime.add(-30, :day)
client = Fixtures.Clients.create_client(account: account)
client
|> Ecto.Changeset.change(last_seen_at: one_month_ago)
|> Repo.update!()
assert count_incompatible_for(account, "1.99.3") == 0
end
test "returns 1 when client is two minors behind", %{
account: account,
unprivileged_subject: subject
} do
subject = %{
subject
| context: %Domain.Auth.Context{subject.context | user_agent: "iOS/12.5 connlib/1.0.0"}
}
Fixtures.Clients.create_client(account: account, subject: subject)
assert count_incompatible_for(account, "1.2.3") == 1
end
test "returns 1 when client is one major behind", %{
account: account,
unprivileged_subject: subject
} do
subject = %{
subject
| context: %Domain.Auth.Context{subject.context | user_agent: "iOS/12.5 connlib/0.9.9"}
}
Fixtures.Clients.create_client(account: account, subject: subject)
assert count_incompatible_for(account, "1.2.3") == 1
end
end
describe "count_by_account_id/0" do
test "counts clients for an account", %{account: account} do
Fixtures.Clients.create_client(account: account)

View File

@@ -11,7 +11,7 @@ defmodule Domain.Mailer.NotificationsTest do
}
end
describe "outdated_gateway_email/3" do
describe "outdated_gateway_email/4" do
test "should contain current gateway version and list of outdated gateways", %{
account: account
} do
@@ -23,11 +23,35 @@ defmodule Domain.Mailer.NotificationsTest do
current_version = "3.2.1"
set_current_version(current_version)
email_body = outdated_gateway_email(account, [gateway_1, gateway_2], admin_email)
incompatible_client_count = 5
email_body =
outdated_gateway_email(
account,
[gateway_1, gateway_2],
incompatible_client_count,
admin_email
)
assert email_body.text_body =~ "The latest Firezone Gateway release is: #{current_version}"
assert email_body.text_body =~ gateway_1.name
assert email_body.text_body =~ gateway_2.name
assert email_body.text_body =~
"#{incompatible_client_count} recently connected client(s) are not compatible"
assert email_body.text_body =~ "See all outdated clients"
assert email_body.html_body =~
"The latest Firezone Gateway release is: <span style=\"font-weight: 600\">#{current_version}</span>"
assert email_body.html_body =~ gateway_1.name
assert email_body.html_body =~ gateway_2.name
assert email_body.html_body =~
"/#{account.id}/clients?clients_order_by=clients%3Aasc%3Alast_seen_version\" target=\"_blank\">#{incompatible_client_count} recently connected client(s)</a> are not compatible"
assert email_body.html_body =~ "See all outdated clients"
end
end

View File

@@ -78,6 +78,50 @@ defmodule Web.Clients.Components do
end
end
@doc """
Renders a version badge with the current version and icon based on whether the component is outdated.
"""
attr :current, :string, required: true
attr :latest, :string
def version(assigns) do
assigns =
assign(assigns, outdated?: not is_nil(assigns.latest) and assigns.current != assigns.latest)
~H"""
<span class="flex items-center">
<.popover>
<:target>
{# icon viewbox is ever so slightly off, hence the top adjustment}
<.icon
:if={@outdated?}
name="hero-arrow-up-circle"
class="relative top-[-0.5px] h-4 w-4 text-orange-500 mr-1"
/>
<.icon
:if={not @outdated?}
name="hero-check-circle"
class="relative top-[-0.5px] h-4 w-4 text-green-500 mr-1"
/>
</:target>
<:content>
<p :if={not @outdated?}>
This component is up to date.
</p>
<p :if={@outdated?}>
A newer version
<.website_link path="/changelog">{@latest}</.website_link>
is available.
</p>
</:content>
</.popover>
<span>
{@current}
</span>
</span>
"""
end
# This is more complex than it needs to be, but
# connlib can send "Mac OS" (with a space) violating the User-Agent spec
defp get_client_os_name_and_version(user_agent) do

View File

@@ -2,7 +2,7 @@ defmodule Web.Clients.Index do
use Web, :live_view
import Web.Actors.Components
import Web.Clients.Components
alias Domain.Clients
alias Domain.{Clients, ComponentVersions}
def mount(_params, _session, socket) do
if connected?(socket) do
@@ -16,6 +16,7 @@ defmodule Web.Clients.Index do
query_module: Clients.Client.Query,
sortable_fields: [
{:clients, :name},
{:clients, :last_seen_version},
{:clients, :last_seen_at},
{:clients, :inserted_at},
{:clients, :last_seen_user_agent}
@@ -100,6 +101,12 @@ defmodule Web.Clients.Index do
<.actor_name_and_role account={@account} actor={client.actor} />
</.link>
</:col>
<:col :let={client} field={{:clients, :last_seen_version}} label="version">
<.version
current={client.last_seen_version}
latest={ComponentVersions.client_version(client)}
/>
</:col>
<:col :let={client} label="status">
<.connection_status schema={client} />
</:col>

View File

@@ -2,7 +2,7 @@ defmodule Web.Clients.Show do
use Web, :live_view
import Web.Policies.Components
import Web.Clients.Components
alias Domain.{Clients, Flows}
alias Domain.{Clients, ComponentVersions, Flows}
def mount(%{"id" => id}, _session, socket) do
with {:ok, client} <-
@@ -144,7 +144,12 @@ defmodule Web.Clients.Show do
</.vertical_table_row>
<.vertical_table_row>
<:label>Version</:label>
<:value>{@client.last_seen_version}</:value>
<:value>
<.version
current={@client.last_seen_version}
latest={ComponentVersions.client_version(@client)}
/>
</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>User agent</:label>

View File

@@ -93,6 +93,7 @@ defmodule Web.Live.Clients.IndexTest do
|> render()
|> table_to_map()
|> with_table_row("name", online_client.name, fn row ->
assert row["version"] =~ "1.3.0"
assert row["status"] == "Online"
name = Repo.preload(online_client, :actor).actor.name
assert row["user"] =~ name
@@ -101,6 +102,7 @@ defmodule Web.Live.Clients.IndexTest do
assert row["created"]
end)
|> with_table_row("name", offline_client.name, fn row ->
assert row["version"] =~ "1.3.0"
assert row["status"] == "Offline"
name = Repo.preload(offline_client, :actor).actor.name
assert row["user"] =~ name