mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
Flows activity/metrics (#2176)
Charts library could be better, I did not find a way to configure time-series min/max value or step, formatting Y axis is not trivial too, but for an early feature this should do the job: <img width="1728" alt="Screenshot 2023-09-27 at 20 00 10" src="https://github.com/firezone/firezone/assets/1877644/8e4bef6b-2937-4dc2-ac31-3c61e31bffc6">
This commit is contained in:
@@ -221,7 +221,7 @@ defmodule API.Client.Channel do
|
||||
|
||||
OpenTelemetry.Tracer.with_span "client.reuse_connection", attrs do
|
||||
with {:ok, gateway} <- Gateways.fetch_gateway_by_id(gateway_id, socket.assigns.subject),
|
||||
{:ok, resource, _flow} <-
|
||||
{:ok, resource, flow} <-
|
||||
Flows.authorize_flow(
|
||||
socket.assigns.client,
|
||||
gateway,
|
||||
@@ -239,6 +239,7 @@ defmodule API.Client.Channel do
|
||||
%{
|
||||
client_id: socket.assigns.client.id,
|
||||
resource_id: resource.id,
|
||||
flow_id: flow.id,
|
||||
authorization_expires_at: socket.assigns.subject.expires_at
|
||||
}, {opentelemetry_ctx, opentelemetry_span_ctx}}
|
||||
)
|
||||
@@ -273,7 +274,7 @@ defmodule API.Client.Channel do
|
||||
|
||||
OpenTelemetry.Tracer.with_span "client.request_connection", ctx_attrs do
|
||||
with {:ok, gateway} <- Gateways.fetch_gateway_by_id(gateway_id, socket.assigns.subject),
|
||||
{:ok, resource, _flow} <-
|
||||
{:ok, resource, flow} <-
|
||||
Flows.authorize_flow(
|
||||
socket.assigns.client,
|
||||
gateway,
|
||||
@@ -291,6 +292,7 @@ defmodule API.Client.Channel do
|
||||
%{
|
||||
client_id: socket.assigns.client.id,
|
||||
resource_id: resource.id,
|
||||
flow_id: flow.id,
|
||||
authorization_expires_at: socket.assigns.subject.expires_at,
|
||||
client_rtc_session_description: client_rtc_session_description,
|
||||
client_preshared_key: preshared_key
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
defmodule API.Gateway.Channel do
|
||||
use API, :channel
|
||||
alias API.Gateway.Views
|
||||
alias Domain.{Clients, Resources, Relays, Gateways}
|
||||
alias Domain.{Clients, Resources, Relays, Gateways, Flows}
|
||||
require Logger
|
||||
require OpenTelemetry.Tracer
|
||||
|
||||
@@ -83,6 +83,7 @@ defmodule API.Gateway.Channel do
|
||||
%{
|
||||
client_id: client_id,
|
||||
resource_id: resource_id,
|
||||
flow_id: flow_id,
|
||||
authorization_expires_at: authorization_expires_at
|
||||
} = attrs
|
||||
|
||||
@@ -90,6 +91,7 @@ defmodule API.Gateway.Channel do
|
||||
|
||||
push(socket, "allow_access", %{
|
||||
client_id: client_id,
|
||||
flow_id: flow_id,
|
||||
resource: Views.Resource.render(resource),
|
||||
expires_at: DateTime.to_unix(authorization_expires_at, :second)
|
||||
})
|
||||
@@ -130,6 +132,7 @@ defmodule API.Gateway.Channel do
|
||||
%{
|
||||
client_id: client_id,
|
||||
resource_id: resource_id,
|
||||
flow_id: flow_id,
|
||||
authorization_expires_at: authorization_expires_at,
|
||||
client_rtc_session_description: rtc_session_description,
|
||||
client_preshared_key: preshared_key
|
||||
@@ -148,6 +151,7 @@ defmodule API.Gateway.Channel do
|
||||
|
||||
push(socket, "request_connection", %{
|
||||
ref: ref,
|
||||
flow_id: flow_id,
|
||||
actor: Views.Actor.render(client.actor),
|
||||
relays: Views.Relay.render_many(relays, authorization_expires_at),
|
||||
resource: Views.Resource.render(resource),
|
||||
@@ -158,6 +162,7 @@ defmodule API.Gateway.Channel do
|
||||
Logger.debug("Awaiting gateway connection_ready message",
|
||||
client_id: client_id,
|
||||
resource_id: resource_id,
|
||||
flow_id: flow_id,
|
||||
ref: ref
|
||||
)
|
||||
|
||||
@@ -236,21 +241,45 @@ defmodule API.Gateway.Channel do
|
||||
end
|
||||
end
|
||||
|
||||
# def handle_in("metrics", params, socket) do
|
||||
# %{
|
||||
# "started_at" => started_at,
|
||||
# "ended_at" => ended_at,
|
||||
# "metrics" => [
|
||||
# %{
|
||||
# "client_id" => client_id,
|
||||
# "resource_id" => resource_id,
|
||||
# "rx_bytes" => 0,
|
||||
# "tx_packets" => 0
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
def handle_in(
|
||||
"metrics",
|
||||
%{
|
||||
"started_at" => started_at,
|
||||
"ended_at" => ended_at,
|
||||
"metrics" => metrics
|
||||
},
|
||||
socket
|
||||
) do
|
||||
OpenTelemetry.Ctx.attach(socket.assigns.opentelemetry_ctx)
|
||||
OpenTelemetry.Tracer.set_current_span(socket.assigns.opentelemetry_span_ctx)
|
||||
|
||||
# :ok = Gateways.update_metrics(socket.assigns.relay, metrics)
|
||||
# {:noreply, socket}
|
||||
# end
|
||||
OpenTelemetry.Tracer.with_span "gateway.metrics" do
|
||||
window_started_at = DateTime.from_unix!(started_at, :second)
|
||||
window_ended_at = DateTime.from_unix!(ended_at, :second)
|
||||
|
||||
activities =
|
||||
Enum.map(metrics, fn metric ->
|
||||
%{
|
||||
"flow_id" => flow_id,
|
||||
"destination" => destination,
|
||||
"rx_bytes" => rx_bytes,
|
||||
"tx_bytes" => tx_bytes
|
||||
} = metric
|
||||
|
||||
%{
|
||||
window_started_at: window_started_at,
|
||||
window_ended_at: window_ended_at,
|
||||
destination: destination,
|
||||
rx_bytes: rx_bytes,
|
||||
tx_bytes: tx_bytes,
|
||||
flow_id: flow_id,
|
||||
account_id: socket.assigns.gateway.account_id
|
||||
}
|
||||
end)
|
||||
|
||||
{:ok, _num} = Flows.upsert_activities(activities)
|
||||
|
||||
{:reply, :ok, socket}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -72,6 +72,7 @@ defmodule API.Gateway.ChannelTest do
|
||||
} do
|
||||
expires_at = DateTime.utc_now() |> DateTime.add(30, :second)
|
||||
otel_ctx = {OpenTelemetry.Ctx.new(), OpenTelemetry.Tracer.start_span("connect")}
|
||||
flow_id = Ecto.UUID.generate()
|
||||
|
||||
stamp_secret = Ecto.UUID.generate()
|
||||
:ok = Domain.Relays.connect_relay(relay, stamp_secret)
|
||||
@@ -82,6 +83,7 @@ defmodule API.Gateway.ChannelTest do
|
||||
%{
|
||||
client_id: client.id,
|
||||
resource_id: resource.id,
|
||||
flow_id: flow_id,
|
||||
authorization_expires_at: expires_at
|
||||
}, otel_ctx}
|
||||
)
|
||||
@@ -102,6 +104,7 @@ defmodule API.Gateway.ChannelTest do
|
||||
]
|
||||
}
|
||||
|
||||
assert payload.flow_id == flow_id
|
||||
assert payload.client_id == client.id
|
||||
assert DateTime.from_unix!(payload.expires_at) == DateTime.truncate(expires_at, :second)
|
||||
end
|
||||
@@ -142,6 +145,7 @@ defmodule API.Gateway.ChannelTest do
|
||||
expires_at = DateTime.utc_now() |> DateTime.add(30, :second)
|
||||
preshared_key = "PSK"
|
||||
rtc_session_description = "RTC_SD"
|
||||
flow_id = Ecto.UUID.generate()
|
||||
|
||||
otel_ctx = {OpenTelemetry.Ctx.new(), OpenTelemetry.Tracer.start_span("connect")}
|
||||
|
||||
@@ -154,6 +158,7 @@ defmodule API.Gateway.ChannelTest do
|
||||
%{
|
||||
client_id: client.id,
|
||||
resource_id: resource.id,
|
||||
flow_id: flow_id,
|
||||
authorization_expires_at: expires_at,
|
||||
client_rtc_session_description: rtc_session_description,
|
||||
client_preshared_key: preshared_key
|
||||
@@ -163,6 +168,7 @@ defmodule API.Gateway.ChannelTest do
|
||||
assert_push "request_connection", payload
|
||||
|
||||
assert is_binary(payload.ref)
|
||||
assert payload.flow_id == flow_id
|
||||
assert payload.actor == %{id: client.actor_id}
|
||||
|
||||
ipv4_stun_uri = "stun:#{relay.ipv4}:#{relay.port}"
|
||||
@@ -246,6 +252,7 @@ defmodule API.Gateway.ChannelTest do
|
||||
preshared_key = "PSK"
|
||||
gateway_public_key = gateway.public_key
|
||||
rtc_session_description = "RTC_SD"
|
||||
flow_id = Ecto.UUID.generate()
|
||||
|
||||
otel_ctx = {OpenTelemetry.Ctx.new(), OpenTelemetry.Tracer.start_span("connect")}
|
||||
|
||||
@@ -259,12 +266,13 @@ defmodule API.Gateway.ChannelTest do
|
||||
client_id: client.id,
|
||||
resource_id: resource.id,
|
||||
authorization_expires_at: expires_at,
|
||||
flow_id: flow_id,
|
||||
client_rtc_session_description: rtc_session_description,
|
||||
client_preshared_key: preshared_key
|
||||
}, otel_ctx}
|
||||
)
|
||||
|
||||
assert_push "request_connection", %{ref: ref}
|
||||
assert_push "request_connection", %{ref: ref, flow_id: ^flow_id}
|
||||
|
||||
push_ref =
|
||||
push(socket, "connection_ready", %{
|
||||
@@ -317,4 +325,55 @@ defmodule API.Gateway.ChannelTest do
|
||||
assert gateway.id == gateway_id
|
||||
end
|
||||
end
|
||||
|
||||
describe "handle_in/3 metrics" do
|
||||
test "inserts activities", %{
|
||||
account: account,
|
||||
subject: subject,
|
||||
client: client,
|
||||
gateway: gateway,
|
||||
resource: resource,
|
||||
socket: socket
|
||||
} do
|
||||
flow =
|
||||
Fixtures.Flows.create_flow(
|
||||
account: account,
|
||||
subject: subject,
|
||||
client: client,
|
||||
resource: resource,
|
||||
gateway: gateway
|
||||
)
|
||||
|
||||
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
one_minute_ago = DateTime.add(now, -1, :minute)
|
||||
|
||||
{:ok, destination} = Domain.Types.IPPort.cast("127.0.0.1:80")
|
||||
|
||||
attrs =
|
||||
%{
|
||||
"started_at" => DateTime.to_unix(one_minute_ago),
|
||||
"ended_at" => DateTime.to_unix(now),
|
||||
"metrics" => [
|
||||
%{
|
||||
"flow_id" => flow.id,
|
||||
"destination" => destination,
|
||||
"rx_bytes" => 100,
|
||||
"tx_bytes" => 200
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
push_ref = push(socket, "metrics", attrs)
|
||||
assert_reply push_ref, :ok
|
||||
|
||||
assert upserted_activity = Repo.one(Domain.Flows.Activity)
|
||||
assert upserted_activity.window_started_at == one_minute_ago
|
||||
assert upserted_activity.window_ended_at == now
|
||||
assert upserted_activity.destination == destination
|
||||
assert upserted_activity.rx_bytes == 100
|
||||
assert upserted_activity.tx_bytes == 200
|
||||
assert upserted_activity.flow_id == flow.id
|
||||
assert upserted_activity.account_id == account.id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
defmodule Domain.Flows do
|
||||
alias Domain.Repo
|
||||
alias Domain.{Auth, Clients, Gateways, Resources, Policies}
|
||||
alias Domain.Flows.{Authorizer, Flow}
|
||||
alias Domain.{Repo, Validator}
|
||||
alias Domain.{Auth, Accounts, Clients, Gateways, Resources, Policies}
|
||||
alias Domain.Flows.{Authorizer, Flow, Activity}
|
||||
require Ecto.Query
|
||||
|
||||
def authorize_flow(
|
||||
@@ -49,35 +49,53 @@ defmodule Domain.Flows do
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_flow_by_id(id, %Auth.Subject{} = subject, opts \\ []) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.view_flows_permission()),
|
||||
true <- Validator.valid_uuid?(id) do
|
||||
{preload, _opts} = Keyword.pop(opts, :preload, [])
|
||||
|
||||
Flow.Query.by_id(id)
|
||||
|> Authorizer.for_subject(Flow, subject)
|
||||
|> Repo.fetch()
|
||||
|> case do
|
||||
{:ok, resource} -> {:ok, Repo.preload(resource, preload)}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
else
|
||||
false -> {:error, :not_found}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
def list_flows_for(assoc, subject, opts \\ [])
|
||||
|
||||
def list_flows_for(%Policies.Policy{} = policy, %Auth.Subject{} = subject, opts) do
|
||||
Flow.Query.by_policy_id(policy.id)
|
||||
|> list(subject, opts)
|
||||
|> list_flows(subject, opts)
|
||||
end
|
||||
|
||||
def list_flows_for(%Resources.Resource{} = resource, %Auth.Subject{} = subject, opts) do
|
||||
Flow.Query.by_resource_id(resource.id)
|
||||
|> list(subject, opts)
|
||||
|> list_flows(subject, opts)
|
||||
end
|
||||
|
||||
def list_flows_for(%Clients.Client{} = client, %Auth.Subject{} = subject, opts) do
|
||||
Flow.Query.by_client_id(client.id)
|
||||
|> list(subject, opts)
|
||||
|> list_flows(subject, opts)
|
||||
end
|
||||
|
||||
def list_flows_for(%Gateways.Gateway{} = gateway, %Auth.Subject{} = subject, opts) do
|
||||
Flow.Query.by_gateway_id(gateway.id)
|
||||
|> list(subject, opts)
|
||||
|> list_flows(subject, opts)
|
||||
end
|
||||
|
||||
defp list(queryable, subject, opts) do
|
||||
defp list_flows(queryable, subject, opts) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.view_flows_permission()) do
|
||||
{preload, _opts} = Keyword.pop(opts, :preload, [])
|
||||
|
||||
{:ok, flows} =
|
||||
queryable
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Authorizer.for_subject(Flow, subject)
|
||||
|> Ecto.Query.order_by([flows: flows], desc: flows.inserted_at, desc: flows.id)
|
||||
|> Ecto.Query.limit(50)
|
||||
|> Repo.list()
|
||||
@@ -85,4 +103,42 @@ defmodule Domain.Flows do
|
||||
{:ok, Repo.preload(flows, preload)}
|
||||
end
|
||||
end
|
||||
|
||||
def upsert_activities(activities) do
|
||||
{num, _} =
|
||||
Repo.insert_all(Activity, activities, on_conflict: :nothing)
|
||||
|
||||
{:ok, num}
|
||||
end
|
||||
|
||||
def list_flow_activities_for(
|
||||
%Flow{} = flow,
|
||||
ended_after,
|
||||
started_before,
|
||||
%Auth.Subject{} = subject
|
||||
) do
|
||||
Activity.Query.by_flow_id(flow.id)
|
||||
|> list_activities(ended_after, started_before, subject)
|
||||
end
|
||||
|
||||
def list_flow_activities_for(
|
||||
%Accounts.Account{} = account,
|
||||
ended_after,
|
||||
started_before,
|
||||
%Auth.Subject{} = subject
|
||||
) do
|
||||
Activity.Query.by_account_id(account.id)
|
||||
|> list_activities(ended_after, started_before, subject)
|
||||
end
|
||||
|
||||
defp list_activities(queryable, ended_after, started_before, subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.view_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()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
15
elixir/apps/domain/lib/domain/flows/activity.ex
Normal file
15
elixir/apps/domain/lib/domain/flows/activity.ex
Normal file
@@ -0,0 +1,15 @@
|
||||
defmodule Domain.Flows.Activity do
|
||||
use Domain, :schema
|
||||
|
||||
schema "flow_activities" do
|
||||
field :window_started_at, :utc_datetime
|
||||
field :window_ended_at, :utc_datetime
|
||||
|
||||
field :destination, Domain.Types.IPPort
|
||||
field :rx_bytes, :integer
|
||||
field :tx_bytes, :integer
|
||||
|
||||
belongs_to :flow, Domain.Flows.Flow
|
||||
belongs_to :account, Domain.Accounts.Account
|
||||
end
|
||||
end
|
||||
23
elixir/apps/domain/lib/domain/flows/activity/query.ex
Normal file
23
elixir/apps/domain/lib/domain/flows/activity/query.ex
Normal file
@@ -0,0 +1,23 @@
|
||||
defmodule Domain.Flows.Activity.Query do
|
||||
use Domain, :query
|
||||
|
||||
def all do
|
||||
from(activities in Domain.Flows.Activity, as: :activities)
|
||||
end
|
||||
|
||||
def by_account_id(queryable \\ all(), account_id) do
|
||||
where(queryable, [activities: activities], activities.account_id == ^account_id)
|
||||
end
|
||||
|
||||
def by_flow_id(queryable \\ all(), flow_id) do
|
||||
where(queryable, [activities: activities], activities.flow_id == ^flow_id)
|
||||
end
|
||||
|
||||
def by_window_started_at(queryable \\ all(), {:less_than, datetime}) do
|
||||
where(queryable, [activities: activities], activities.window_started_at < ^datetime)
|
||||
end
|
||||
|
||||
def by_window_ended_at(queryable \\ all(), {:greater_than, datetime}) do
|
||||
where(queryable, [activities: activities], activities.window_ended_at > ^datetime)
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
defmodule Domain.Flows.Authorizer do
|
||||
use Domain.Auth.Authorizer
|
||||
alias Domain.Flows.Flow
|
||||
alias Domain.Flows.{Flow, Activity}
|
||||
|
||||
def view_flows_permission, do: build(Flow, :view)
|
||||
def create_flows_permission, do: build(Flow, :create)
|
||||
@@ -23,11 +23,17 @@ defmodule Domain.Flows.Authorizer do
|
||||
[]
|
||||
end
|
||||
|
||||
@impl Domain.Auth.Authorizer
|
||||
def for_subject(queryable, %Subject{} = subject) do
|
||||
def for_subject(queryable, Flow, %Subject{} = subject) do
|
||||
cond do
|
||||
has_permission?(subject, view_flows_permission()) ->
|
||||
Flow.Query.by_account_id(queryable, subject.account.id)
|
||||
end
|
||||
end
|
||||
|
||||
def for_subject(queryable, Activity, %Subject{} = subject) do
|
||||
cond do
|
||||
has_permission?(subject, view_flows_permission()) ->
|
||||
Activity.Query.by_account_id(queryable, subject.account.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -29,11 +29,11 @@ defmodule Domain.Resources do
|
||||
end
|
||||
|
||||
def fetch_and_authorize_resource_by_id(id, %Auth.Subject{} = subject, opts \\ []) do
|
||||
{preload, _opts} = Keyword.pop(opts, :preload, [])
|
||||
|
||||
with :ok <-
|
||||
Auth.ensure_has_permissions(subject, Authorizer.view_available_resources_permission()),
|
||||
true <- Validator.valid_uuid?(id) do
|
||||
{preload, _opts} = Keyword.pop(opts, :preload, [])
|
||||
|
||||
Resource.Query.by_id(id)
|
||||
|> Resource.Query.by_account_id(subject.account.id)
|
||||
|> Resource.Query.by_authorized_actor_id(subject.actor.id)
|
||||
|
||||
@@ -65,8 +65,12 @@ defmodule Domain.Types.IPPort do
|
||||
|
||||
def dump(_), do: :error
|
||||
|
||||
def load(%__MODULE__{} = ip) do
|
||||
{:ok, ip}
|
||||
def load(binary) when is_binary(binary) do
|
||||
cast(binary)
|
||||
end
|
||||
|
||||
def load(%__MODULE__{} = struct) do
|
||||
{:ok, struct}
|
||||
end
|
||||
|
||||
def load(_), do: :error
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
defmodule Domain.Repo.Migrations.AddFlowActivities do
|
||||
use Ecto.Migration
|
||||
|
||||
@assoc_opts [type: :binary_id, on_delete: :delete_all]
|
||||
|
||||
def change do
|
||||
create table(:flow_activities, primary_key: false) do
|
||||
add(:id, :uuid, primary_key: true)
|
||||
|
||||
add(:window_started_at, :utc_datetime_usec, null: false)
|
||||
add(:window_ended_at, :utc_datetime_usec, null: false)
|
||||
|
||||
add(:destination, :string, null: false)
|
||||
add(:rx_bytes, :bigint, null: false)
|
||||
add(:tx_bytes, :bigint, null: false)
|
||||
|
||||
add(:flow_id, references(:flows, @assoc_opts), null: false)
|
||||
add(:account_id, references(:accounts, @assoc_opts), null: false)
|
||||
end
|
||||
|
||||
execute("""
|
||||
CREATE UNIQUE INDEX flow_activities_account_id_flow_id_window_destination_index ON flow_activities
|
||||
USING BTREE (account_id, flow_id, window_started_at, window_ended_at, destination);
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE INDEX flow_activities_account_id_flow_id_window_index ON flow_activities
|
||||
USING BTREE (account_id, flow_id, window_started_at ASC);
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE INDEX flow_activities_account_id_window_index ON flow_activities
|
||||
USING BTREE (account_id, window_started_at ASC);
|
||||
""")
|
||||
end
|
||||
end
|
||||
@@ -457,9 +457,45 @@ IO.puts("Created client tokens:")
|
||||
IO.puts(" #{unprivileged_actor_email} token: #{unprivileged_subject_client_token}")
|
||||
IO.puts("")
|
||||
|
||||
Flows.authorize_flow(
|
||||
user_iphone,
|
||||
gateway1,
|
||||
cidr_resource.id,
|
||||
unprivileged_subject
|
||||
)
|
||||
{:ok, _resource, flow} =
|
||||
Flows.authorize_flow(
|
||||
user_iphone,
|
||||
gateway1,
|
||||
cidr_resource.id,
|
||||
unprivileged_subject
|
||||
)
|
||||
|
||||
started_at =
|
||||
DateTime.utc_now()
|
||||
|> DateTime.truncate(:second)
|
||||
|> DateTime.add(5, :minute)
|
||||
|
||||
{:ok, destination1} = Domain.Types.IPPort.cast("142.250.217.142:443")
|
||||
{:ok, destination2} = Domain.Types.IPPort.cast("142.250.217.142:80")
|
||||
|
||||
random_integer = fn ->
|
||||
:math.pow(10, 10)
|
||||
|> round()
|
||||
|> :rand.uniform()
|
||||
|> floor()
|
||||
|> Kernel.-(1)
|
||||
end
|
||||
|
||||
activities =
|
||||
for i <- 1..200 do
|
||||
offset = i * 15
|
||||
started_at = DateTime.add(started_at, offset, :minute)
|
||||
ended_at = DateTime.add(started_at, 15, :minute)
|
||||
|
||||
%{
|
||||
window_started_at: started_at,
|
||||
window_ended_at: ended_at,
|
||||
destination: Enum.random([destination1, destination2]),
|
||||
rx_bytes: random_integer.(),
|
||||
tx_bytes: random_integer.(),
|
||||
flow_id: flow.id,
|
||||
account_id: account.id
|
||||
}
|
||||
end
|
||||
|
||||
{:ok, 200} = Flows.upsert_activities(activities)
|
||||
|
||||
@@ -1222,7 +1222,7 @@ defmodule Domain.ActorsTest do
|
||||
|
||||
{:ok, actor} = fetch_actor_by_id(actor.id, subject, preload: :identities)
|
||||
|
||||
assert Ecto.assoc_loaded?(actor.identities) == true
|
||||
assert Ecto.assoc_loaded?(actor.identities)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1317,7 +1317,7 @@ defmodule Domain.ActorsTest do
|
||||
{:ok, actors} = list_actors(subject, preload: :identities)
|
||||
assert length(actors) == 2
|
||||
|
||||
assert Enum.all?(actors, fn a -> Ecto.assoc_loaded?(a.identities) end) == true
|
||||
assert Enum.all?(actors, fn a -> Ecto.assoc_loaded?(a.identities) end)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ defmodule Domain.FlowsTest do
|
||||
use Domain.DataCase, async: true
|
||||
import Domain.Flows
|
||||
alias Domain.Flows
|
||||
alias Domain.Flows.Authorizer
|
||||
|
||||
setup do
|
||||
account = Fixtures.Accounts.create_account()
|
||||
@@ -181,11 +182,95 @@ defmodule Domain.FlowsTest do
|
||||
assert {:ok, resource, _flow} =
|
||||
authorize_flow(client, gateway, resource.id, subject, preload: :connections)
|
||||
|
||||
assert Ecto.assoc_loaded?(resource.connections) == true
|
||||
assert Ecto.assoc_loaded?(resource.connections)
|
||||
assert Ecto.assoc_loaded?(resource.connections)
|
||||
assert Ecto.assoc_loaded?(resource.connections)
|
||||
assert Ecto.assoc_loaded?(resource.connections)
|
||||
assert length(resource.connections) == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_flow_by_id/2" do
|
||||
test "returns error when flow does not exist", %{subject: subject} do
|
||||
assert fetch_flow_by_id(Ecto.UUID.generate(), subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when UUID is invalid", %{subject: subject} do
|
||||
assert fetch_flow_by_id("foo", subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns flow", %{
|
||||
account: account,
|
||||
client: client,
|
||||
gateway: gateway,
|
||||
resource: resource,
|
||||
policy: policy,
|
||||
subject: subject
|
||||
} do
|
||||
flow =
|
||||
Fixtures.Flows.create_flow(
|
||||
account: account,
|
||||
subject: subject,
|
||||
client: client,
|
||||
policy: policy,
|
||||
resource: resource,
|
||||
gateway: gateway
|
||||
)
|
||||
|
||||
assert {:ok, fetched_flow} = fetch_flow_by_id(flow.id, subject)
|
||||
assert fetched_flow.id == flow.id
|
||||
end
|
||||
|
||||
test "does not return flows in other accounts", %{subject: subject} do
|
||||
flow = Fixtures.Flows.create_flow()
|
||||
assert fetch_flow_by_id(flow.id, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to view flows", %{subject: subject} do
|
||||
subject = Fixtures.Auth.remove_permissions(subject)
|
||||
|
||||
assert fetch_flow_by_id(Ecto.UUID.generate(), subject) ==
|
||||
{:error,
|
||||
{:unauthorized, [missing_permissions: [Authorizer.view_flows_permission()]]}}
|
||||
end
|
||||
|
||||
test "associations are preloaded when opts given", %{
|
||||
account: account,
|
||||
client: client,
|
||||
gateway: gateway,
|
||||
resource: resource,
|
||||
policy: policy,
|
||||
subject: subject
|
||||
} do
|
||||
flow =
|
||||
Fixtures.Flows.create_flow(
|
||||
account: account,
|
||||
subject: subject,
|
||||
client: client,
|
||||
policy: policy,
|
||||
resource: resource,
|
||||
gateway: gateway
|
||||
)
|
||||
|
||||
assert {:ok, flow} =
|
||||
fetch_flow_by_id(flow.id, subject,
|
||||
preload: [
|
||||
:policy,
|
||||
:client,
|
||||
:gateway,
|
||||
:resource,
|
||||
:account
|
||||
]
|
||||
)
|
||||
|
||||
assert Ecto.assoc_loaded?(flow.policy)
|
||||
assert Ecto.assoc_loaded?(flow.client)
|
||||
assert Ecto.assoc_loaded?(flow.gateway)
|
||||
assert Ecto.assoc_loaded?(flow.resource)
|
||||
assert Ecto.assoc_loaded?(flow.account)
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_flows_for/2" do
|
||||
test "returns empty list when there are no flows", %{
|
||||
client: client,
|
||||
@@ -215,7 +300,7 @@ defmodule Domain.FlowsTest do
|
||||
assert list_flows_for(gateway, subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "returns all authorized resources for account user subject", %{
|
||||
test "returns all authorized flows", %{
|
||||
account: account,
|
||||
client: client,
|
||||
gateway: gateway,
|
||||
@@ -258,4 +343,232 @@ defmodule Domain.FlowsTest do
|
||||
assert list_flows_for(gateway, subject) == expected_error
|
||||
end
|
||||
end
|
||||
|
||||
describe "upsert_activities/1" do
|
||||
test "inserts new activities", %{
|
||||
account: account,
|
||||
client: client,
|
||||
gateway: gateway,
|
||||
resource: resource,
|
||||
policy: policy,
|
||||
subject: subject
|
||||
} do
|
||||
flow =
|
||||
Fixtures.Flows.create_flow(
|
||||
account: account,
|
||||
subject: subject,
|
||||
client: client,
|
||||
policy: policy,
|
||||
resource: resource,
|
||||
gateway: gateway
|
||||
)
|
||||
|
||||
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
|
||||
{:ok, destination} = Domain.Types.IPPort.cast("127.0.0.1:80")
|
||||
|
||||
activity =
|
||||
%{
|
||||
window_started_at: DateTime.add(now, -1, :minute),
|
||||
window_ended_at: now,
|
||||
destination: destination,
|
||||
rx_bytes: 100,
|
||||
tx_bytes: 200,
|
||||
flow_id: flow.id,
|
||||
account_id: account.id
|
||||
}
|
||||
|
||||
assert upsert_activities([activity]) == {:ok, 1}
|
||||
|
||||
assert upserted_activity = Repo.one(Flows.Activity)
|
||||
assert upserted_activity.window_started_at == activity.window_started_at
|
||||
assert upserted_activity.window_ended_at == activity.window_ended_at
|
||||
assert upserted_activity.destination == destination
|
||||
assert upserted_activity.rx_bytes == activity.rx_bytes
|
||||
assert upserted_activity.tx_bytes == activity.tx_bytes
|
||||
assert upserted_activity.flow_id == flow.id
|
||||
assert upserted_activity.account_id == account.id
|
||||
end
|
||||
|
||||
test "ignores upsert conflicts", %{
|
||||
account: account,
|
||||
client: client,
|
||||
gateway: gateway,
|
||||
resource: resource,
|
||||
policy: policy,
|
||||
subject: subject
|
||||
} do
|
||||
flow =
|
||||
Fixtures.Flows.create_flow(
|
||||
account: account,
|
||||
subject: subject,
|
||||
client: client,
|
||||
policy: policy,
|
||||
resource: resource,
|
||||
gateway: gateway
|
||||
)
|
||||
|
||||
activity = Fixtures.Flows.activity_attrs(flow_id: flow.id, account_id: account.id)
|
||||
|
||||
assert upsert_activities([activity]) == {:ok, 1}
|
||||
assert upsert_activities([activity]) == {:ok, 0}
|
||||
|
||||
assert Repo.one(Flows.Activity)
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_flow_activities_for/4" do
|
||||
setup %{
|
||||
account: account,
|
||||
client: client,
|
||||
gateway: gateway,
|
||||
resource: resource,
|
||||
policy: policy,
|
||||
subject: subject
|
||||
} do
|
||||
flow =
|
||||
Fixtures.Flows.create_flow(
|
||||
account: account,
|
||||
subject: subject,
|
||||
client: client,
|
||||
policy: policy,
|
||||
resource: resource,
|
||||
gateway: gateway
|
||||
)
|
||||
|
||||
%{flow: flow}
|
||||
end
|
||||
|
||||
test "returns empty list when there are no flow activities", %{
|
||||
account: account,
|
||||
flow: flow,
|
||||
subject: subject
|
||||
} do
|
||||
now = DateTime.utc_now()
|
||||
ended_after = DateTime.add(now, -30, :minute)
|
||||
started_before = DateTime.add(now, 30, :minute)
|
||||
|
||||
assert list_flow_activities_for(account, ended_after, started_before, subject) == {:ok, []}
|
||||
assert list_flow_activities_for(flow, ended_after, started_before, subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "does not list flow activities from other accounts", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} 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 list_flow_activities_for(account, ended_after, started_before, subject) == {:ok, []}
|
||||
assert list_flow_activities_for(flow, ended_after, started_before, subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "returns ordered by window start time flow activities within a time window", %{
|
||||
account: account,
|
||||
flow: flow,
|
||||
subject: subject
|
||||
} do
|
||||
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
thirty_minutes_ago = DateTime.add(now, -30, :minute)
|
||||
five_minutes_ago = DateTime.add(now, -5, :minute)
|
||||
four_minutes_ago = DateTime.add(now, -4, :minute)
|
||||
three_minutes_ago = DateTime.add(now, -4, :minute)
|
||||
thirty_minutes_in_future = DateTime.add(now, 30, :minute)
|
||||
sixty_minutes_in_future = DateTime.add(now, 60, :minute)
|
||||
|
||||
activity1 =
|
||||
Fixtures.Flows.create_activity(
|
||||
flow: flow,
|
||||
window_started_at: four_minutes_ago,
|
||||
window_ended_at: three_minutes_ago
|
||||
)
|
||||
|
||||
assert list_flow_activities_for(
|
||||
account,
|
||||
thirty_minutes_in_future,
|
||||
sixty_minutes_in_future,
|
||||
subject
|
||||
) == {:ok, []}
|
||||
|
||||
assert list_flow_activities_for(
|
||||
flow,
|
||||
thirty_minutes_in_future,
|
||||
sixty_minutes_in_future,
|
||||
subject
|
||||
) == {:ok, []}
|
||||
|
||||
assert list_flow_activities_for(
|
||||
account,
|
||||
thirty_minutes_ago,
|
||||
five_minutes_ago,
|
||||
subject
|
||||
) == {:ok, []}
|
||||
|
||||
assert list_flow_activities_for(
|
||||
flow,
|
||||
thirty_minutes_ago,
|
||||
five_minutes_ago,
|
||||
subject
|
||||
) == {:ok, []}
|
||||
|
||||
assert list_flow_activities_for(
|
||||
account,
|
||||
five_minutes_ago,
|
||||
now,
|
||||
subject
|
||||
) == {:ok, [activity1]}
|
||||
|
||||
assert list_flow_activities_for(
|
||||
flow,
|
||||
five_minutes_ago,
|
||||
now,
|
||||
subject
|
||||
) == {:ok, [activity1]}
|
||||
|
||||
activity2 =
|
||||
Fixtures.Flows.create_activity(
|
||||
flow: flow,
|
||||
window_started_at: five_minutes_ago,
|
||||
window_ended_at: four_minutes_ago
|
||||
)
|
||||
|
||||
assert list_flow_activities_for(
|
||||
account,
|
||||
thirty_minutes_ago,
|
||||
now,
|
||||
subject
|
||||
) == {:ok, [activity2, activity1]}
|
||||
|
||||
assert list_flow_activities_for(
|
||||
flow,
|
||||
thirty_minutes_ago,
|
||||
now,
|
||||
subject
|
||||
) == {:ok, [activity2, activity1]}
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to view flows", %{
|
||||
account: account,
|
||||
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) ==
|
||||
{:error,
|
||||
{:unauthorized, [missing_permissions: [Flows.Authorizer.view_flows_permission()]]}}
|
||||
|
||||
assert list_flow_activities_for(flow, ended_after, started_before, subject) ==
|
||||
{:error,
|
||||
{:unauthorized, [missing_permissions: [Flows.Authorizer.view_flows_permission()]]}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -418,8 +418,8 @@ defmodule Domain.GatewaysTest do
|
||||
gateway = Fixtures.Gateways.create_gateway(account: account)
|
||||
{:ok, gateway} = fetch_gateway_by_id(gateway.id, subject, preload: [:group, :account])
|
||||
|
||||
assert Ecto.assoc_loaded?(gateway.group) == true
|
||||
assert Ecto.assoc_loaded?(gateway.account) == true
|
||||
assert Ecto.assoc_loaded?(gateway.group)
|
||||
assert Ecto.assoc_loaded?(gateway.account)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ defmodule Domain.ResourcesTest do
|
||||
)
|
||||
|
||||
assert {:ok, resource} = fetch_resource_by_id(resource.id, subject, preload: :connections)
|
||||
assert Ecto.assoc_loaded?(resource.connections) == true
|
||||
assert Ecto.assoc_loaded?(resource.connections)
|
||||
assert length(resource.connections) == 1
|
||||
end
|
||||
end
|
||||
@@ -222,7 +222,7 @@ defmodule Domain.ResourcesTest do
|
||||
assert {:ok, resource} =
|
||||
fetch_and_authorize_resource_by_id(resource.id, subject, preload: :connections)
|
||||
|
||||
assert Ecto.assoc_loaded?(resource.connections) == true
|
||||
assert Ecto.assoc_loaded?(resource.connections)
|
||||
assert length(resource.connections) == 1
|
||||
end
|
||||
end
|
||||
|
||||
@@ -84,4 +84,46 @@ defmodule Domain.Fixtures.Flows do
|
||||
})
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
def activity_attrs(attrs \\ %{}) do
|
||||
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
unique_ipv4 = :inet.ntoa(unique_ipv4())
|
||||
{:ok, destination} = Domain.Types.IPPort.cast("#{unique_ipv4}:80")
|
||||
|
||||
Enum.into(attrs, %{
|
||||
window_started_at: DateTime.add(now, -1, :minute),
|
||||
window_ended_at: now,
|
||||
destination: destination,
|
||||
rx_bytes: 100,
|
||||
tx_bytes: 200
|
||||
})
|
||||
end
|
||||
|
||||
def create_activity(attrs) do
|
||||
attrs = activity_attrs(attrs)
|
||||
|
||||
{account, attrs} =
|
||||
pop_assoc_fixture(attrs, :account, fn assoc_attrs ->
|
||||
if relation = attrs[:flow] do
|
||||
Repo.get!(Domain.Accounts.Account, relation.account_id)
|
||||
else
|
||||
Fixtures.Accounts.create_account(assoc_attrs)
|
||||
end
|
||||
end)
|
||||
|
||||
{flow, attrs} =
|
||||
pop_assoc_fixture(attrs, :flow, fn assoc_attrs ->
|
||||
assoc_attrs
|
||||
|> Enum.into(%{account: account})
|
||||
|> create_flow()
|
||||
end)
|
||||
|
||||
attrs =
|
||||
attrs
|
||||
|> Map.put(:flow_id, flow.id)
|
||||
|> Map.put(:account_id, account.id)
|
||||
|
||||
struct(Flows.Activity, attrs)
|
||||
|> Repo.insert!()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,30 +1,28 @@
|
||||
@layer utilities {
|
||||
@variants responsive {
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: block;
|
||||
height: 0px;
|
||||
background-color: initial;
|
||||
border-radius: 10px;
|
||||
transition: all 2s linear;
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: block;
|
||||
height: 0px;
|
||||
background-color: initial;
|
||||
border-radius: 10px;
|
||||
transition: all 2s linear;
|
||||
}
|
||||
|
||||
.no-scrollbar:hover::-webkit-scrollbar {
|
||||
height: .5rem;
|
||||
}
|
||||
.no-scrollbar:hover::-webkit-scrollbar {
|
||||
height: .5rem;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(228 228 231/var(--tw-bg-opacity));
|
||||
border-radius: 10px;
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(228 228 231/var(--tw-bg-opacity));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar-track {
|
||||
background-color: rgb(249 250 251);
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar-track {
|
||||
background-color: rgb(249 250 251);
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ Hooks.Copy = {
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
// Update status indicator when sidebar is mounted or updated
|
||||
let statusIndicatorClassNames = {
|
||||
none: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@fontsource/source-sans-pro": "^4.5.11",
|
||||
"flowbite": "^1.6.5"
|
||||
"flowbite": "^1.8.1"
|
||||
}
|
||||
}
|
||||
|
||||
66
elixir/apps/web/assets/pnpm-lock.yaml
generated
66
elixir/apps/web/assets/pnpm-lock.yaml
generated
@@ -9,8 +9,8 @@ dependencies:
|
||||
specifier: ^4.5.11
|
||||
version: 4.5.11
|
||||
flowbite:
|
||||
specifier: ^1.6.5
|
||||
version: 1.6.5
|
||||
specifier: ^1.8.1
|
||||
version: 1.8.1
|
||||
|
||||
packages:
|
||||
|
||||
@@ -22,8 +22,12 @@ packages:
|
||||
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
||||
dev: false
|
||||
|
||||
/flowbite@1.6.5:
|
||||
resolution: {integrity: sha512-eI4h3pIRI9d7grlYq14r0A01KUtw7189sPLLx/O2i7JyPEWpbleScfYuEc48XTeNjk1xxm/JHgZkD9kjyOWAlA==}
|
||||
/@yr/monotone-cubic-spline@1.0.3:
|
||||
resolution: {integrity: sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==}
|
||||
dev: false
|
||||
|
||||
/flowbite@1.8.1:
|
||||
resolution: {integrity: sha512-lXTcO8a6dRTPFpINyOLcATCN/pK1Of/jY4PryklPllAiqH64tSDUsOdQpar3TO59ZXWwugm2e92oaqwH6X90Xg==}
|
||||
dependencies:
|
||||
'@popperjs/core': 2.11.8
|
||||
mini-svg-data-uri: 1.4.4
|
||||
@@ -33,3 +37,57 @@ packages:
|
||||
resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/svg.draggable.js@2.2.2:
|
||||
resolution: {integrity: sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
dependencies:
|
||||
svg.js: 2.7.1
|
||||
dev: false
|
||||
|
||||
/svg.easing.js@2.0.0:
|
||||
resolution: {integrity: sha512-//ctPdJMGy22YoYGV+3HEfHbm6/69LJUTAqI2/5qBvaNHZ9uUFVC82B0Pl299HzgH13rKrBgi4+XyXXyVWWthA==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
dependencies:
|
||||
svg.js: 2.7.1
|
||||
dev: false
|
||||
|
||||
/svg.filter.js@2.0.2:
|
||||
resolution: {integrity: sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
dependencies:
|
||||
svg.js: 2.7.1
|
||||
dev: false
|
||||
|
||||
/svg.js@2.7.1:
|
||||
resolution: {integrity: sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==}
|
||||
dev: false
|
||||
|
||||
/svg.pathmorphing.js@0.1.3:
|
||||
resolution: {integrity: sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
dependencies:
|
||||
svg.js: 2.7.1
|
||||
dev: false
|
||||
|
||||
/svg.resize.js@1.4.3:
|
||||
resolution: {integrity: sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
dependencies:
|
||||
svg.js: 2.7.1
|
||||
svg.select.js: 2.1.2
|
||||
dev: false
|
||||
|
||||
/svg.select.js@2.1.2:
|
||||
resolution: {integrity: sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
dependencies:
|
||||
svg.js: 2.7.1
|
||||
dev: false
|
||||
|
||||
/svg.select.js@3.0.1:
|
||||
resolution: {integrity: sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
dependencies:
|
||||
svg.js: 2.7.1
|
||||
dev: false
|
||||
|
||||
@@ -6,7 +6,6 @@ const fs = require("fs")
|
||||
const path = require("path")
|
||||
const defaultTheme = require("tailwindcss/defaultTheme")
|
||||
|
||||
|
||||
const firezoneColors = {
|
||||
// See our brand palette in Figma.
|
||||
// These have been reversed to match Tailwind's default order.
|
||||
@@ -70,7 +69,7 @@ module.exports = {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#FD4F00",
|
||||
primary: firezoneColors["heat-wave"],
|
||||
primary: firezoneColors["heat-wave"],
|
||||
accent: firezoneColors["electric-violet"],
|
||||
neutral: firezoneColors["night-rider"]
|
||||
//primary: {
|
||||
@@ -89,7 +88,9 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require("flowbite/plugin"),
|
||||
require('flowbite/plugin')({
|
||||
charts: true,
|
||||
}),
|
||||
require("@tailwindcss/forms"),
|
||||
plugin(({ addVariant }) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])),
|
||||
plugin(({ addVariant }) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
|
||||
|
||||
@@ -264,7 +264,7 @@ defmodule Web.NavigationComponents do
|
||||
Renders a single breadcrumb entry. should be wrapped in <.breadcrumbs> component.
|
||||
"""
|
||||
slot :inner_block, required: true, doc: "The label for the breadcrumb entry."
|
||||
attr :path, :string, required: true, doc: "The path for the breadcrumb entry."
|
||||
attr :path, :string, default: nil, doc: "The path for the breadcrumb entry."
|
||||
|
||||
def breadcrumb(assigns) do
|
||||
~H"""
|
||||
@@ -272,11 +272,19 @@ defmodule Web.NavigationComponents do
|
||||
<div class="flex items-center text-gray-700 dark:text-gray-300">
|
||||
<.icon name="hero-chevron-right-solid" class="w-6 h-6" />
|
||||
<.link
|
||||
:if={not is_nil(@path)}
|
||||
navigate={@path}
|
||||
class="ml-1 text-sm font-medium text-gray-700 hover:text-gray-900 md:ml-2 dark:text-gray-300 dark:hover:text-white"
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</.link>
|
||||
|
||||
<span
|
||||
:if={is_nil(@path)}
|
||||
class="ml-1 text-sm font-medium text-gray-700 hover:text-gray-900 md:ml-2 dark:text-gray-300 dark:hover:text-white"
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
"""
|
||||
|
||||
1
elixir/apps/web/lib/web/csv.ex
Normal file
1
elixir/apps/web/lib/web/csv.ex
Normal file
@@ -0,0 +1 @@
|
||||
NimbleCSV.define(Web.CSV, separator: "\t", escape: "\"")
|
||||
@@ -125,6 +125,14 @@ defmodule Web.Clients.Show do
|
||||
</.link>
|
||||
(<%= flow.gateway_remote_ip %>)
|
||||
</:col>
|
||||
<:col :let={flow} label="ACTIVITY">
|
||||
<.link
|
||||
navigate={~p"/#{@account}/flows/#{flow.id}"}
|
||||
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
|
||||
>
|
||||
Show
|
||||
</.link>
|
||||
</:col>
|
||||
</.table>
|
||||
</div>
|
||||
|
||||
|
||||
38
elixir/apps/web/lib/web/live/flows/download_activities.ex
Normal file
38
elixir/apps/web/lib/web/live/flows/download_activities.ex
Normal file
@@ -0,0 +1,38 @@
|
||||
defmodule Web.Flows.DownloadActivities do
|
||||
use Web, :controller
|
||||
alias Domain.Flows
|
||||
|
||||
def download(conn, %{"id" => id}) do
|
||||
with {:ok, flow} <- Flows.fetch_flow_by_id(id, conn.assigns.subject),
|
||||
{:ok, activities} <-
|
||||
Flows.list_flow_activities_for(
|
||||
flow,
|
||||
flow.inserted_at,
|
||||
flow.expires_at,
|
||||
conn.assigns.subject
|
||||
) do
|
||||
fields = ~w[window_started_at window_ended_at destination rx_bytes tx_bytes]
|
||||
|
||||
rows =
|
||||
Enum.map(activities, fn activity ->
|
||||
[
|
||||
to_string(activity.window_started_at),
|
||||
to_string(activity.window_ended_at),
|
||||
to_string(activity.destination),
|
||||
activity.rx_bytes,
|
||||
activity.tx_bytes
|
||||
]
|
||||
end)
|
||||
|
||||
iodata = Web.CSV.dump_to_iodata(dbg([fields] ++ rows))
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("text/csv")
|
||||
|> put_resp_header("content-disposition", "attachment; filename=\"export.csv\"")
|
||||
|> put_root_layout(false)
|
||||
|> send_resp(200, iodata)
|
||||
else
|
||||
{:error, _reason} -> raise Web.LiveErrors.NotFoundError
|
||||
end
|
||||
end
|
||||
end
|
||||
111
elixir/apps/web/lib/web/live/flows/show.ex
Normal file
111
elixir/apps/web/lib/web/live/flows/show.ex
Normal file
@@ -0,0 +1,111 @@
|
||||
defmodule Web.Flows.Show do
|
||||
use Web, :live_view
|
||||
import Web.Policies.Components
|
||||
alias Domain.{Flows, Flows}
|
||||
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
with {:ok, flow} <-
|
||||
Flows.fetch_flow_by_id(id, socket.assigns.subject,
|
||||
preload: [
|
||||
policy: [:resource, :actor_group],
|
||||
client: [],
|
||||
gateway: [:group],
|
||||
resource: []
|
||||
]
|
||||
) do
|
||||
{:ok, socket, temporary_assigns: [flow: flow]}
|
||||
else
|
||||
{:error, _reason} -> raise Web.LiveErrors.NotFoundError
|
||||
end
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.breadcrumbs account={@account}>
|
||||
<.breadcrumb>Flows</.breadcrumb>
|
||||
<.breadcrumb path={~p"/#{@account}/flows/#{@flow.id}"}>
|
||||
<%= @flow.client.name %> flow
|
||||
</.breadcrumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<.page>
|
||||
<:title>
|
||||
Flow for: <code><%= @flow.client.name %></code>
|
||||
</:title>
|
||||
|
||||
<:action
|
||||
navigate={~p"/#{@account}/flows/#{@flow}/activities.csv"}
|
||||
icon="hero-arrow-down-on-square"
|
||||
>
|
||||
Export to CSV
|
||||
</:action>
|
||||
|
||||
<:content flash={@flash}>
|
||||
<.vertical_table id="flow">
|
||||
<.vertical_table_row>
|
||||
<:label>Authorized At</:label>
|
||||
<:value>
|
||||
<.relative_datetime datetime={@flow.inserted_at} />
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
<.vertical_table_row>
|
||||
<:label>Expires At</:label>
|
||||
<:value>
|
||||
<.relative_datetime datetime={@flow.expires_at} />
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
<.vertical_table_row>
|
||||
<:label>Policy</:label>
|
||||
<:value>
|
||||
<.link
|
||||
navigate={~p"/#{@account}/policies/#{@flow.policy_id}"}
|
||||
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
|
||||
>
|
||||
<.policy_name policy={@flow.policy} />
|
||||
</.link>
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
<.vertical_table_row>
|
||||
<:label>Client</:label>
|
||||
<:value>
|
||||
<.link
|
||||
navigate={~p"/#{@account}/clients/#{@flow.client_id}"}
|
||||
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
|
||||
>
|
||||
<%= @flow.client.name %>
|
||||
</.link>
|
||||
<div>Remote IP: <%= @flow.client_remote_ip %></div>
|
||||
<div>User Agent: <%= @flow.client_user_agent %></div>
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
<.vertical_table_row>
|
||||
<:label>Gateway</:label>
|
||||
<:value>
|
||||
<.link
|
||||
navigate={~p"/#{@account}/gateways/#{@flow.gateway_id}"}
|
||||
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
|
||||
>
|
||||
<%= @flow.gateway.group.name_prefix %>-<%= @flow.gateway.name_suffix %>
|
||||
</.link>
|
||||
<div>
|
||||
Remote IP: <%= @flow.gateway_remote_ip %>
|
||||
</div>
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
<.vertical_table_row>
|
||||
<:label>Resource</:label>
|
||||
<:value>
|
||||
<.link
|
||||
navigate={~p"/#{@account}/resources/#{@flow.resource_id}"}
|
||||
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
|
||||
>
|
||||
<%= @flow.resource.name %>
|
||||
</.link>
|
||||
</:value>
|
||||
</.vertical_table_row>
|
||||
</.vertical_table>
|
||||
</:content>
|
||||
</.page>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -163,6 +163,14 @@ defmodule Web.Gateways.Show do
|
||||
</.link>
|
||||
(<%= flow.client_remote_ip %>)
|
||||
</:col>
|
||||
<:col :let={flow} label="ACTIVITY">
|
||||
<.link
|
||||
navigate={~p"/#{@account}/flows/#{flow.id}"}
|
||||
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
|
||||
>
|
||||
Show
|
||||
</.link>
|
||||
</:col>
|
||||
</.table>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -134,6 +134,14 @@ defmodule Web.Policies.Show do
|
||||
</.link>
|
||||
(<%= flow.gateway_remote_ip %>)
|
||||
</:col>
|
||||
<:col :let={flow} label="ACTIVITY">
|
||||
<.link
|
||||
navigate={~p"/#{@account}/flows/#{flow.id}"}
|
||||
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
|
||||
>
|
||||
Show
|
||||
</.link>
|
||||
</:col>
|
||||
</.table>
|
||||
|
||||
<.header>
|
||||
|
||||
@@ -159,6 +159,14 @@ defmodule Web.Resources.Show do
|
||||
</.link>
|
||||
(<%= flow.gateway_remote_ip %>)
|
||||
</:col>
|
||||
<:col :let={flow} label="ACTIVITY">
|
||||
<.link
|
||||
navigate={~p"/#{@account}/flows/#{flow.id}"}
|
||||
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
|
||||
>
|
||||
Show
|
||||
</.link>
|
||||
</:col>
|
||||
</.table>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -169,6 +169,11 @@ defmodule Web.Router do
|
||||
live "/:id", Show
|
||||
end
|
||||
|
||||
scope "/flows", Flows do
|
||||
live "/:id", Show
|
||||
get "/:id/activities.csv", DownloadActivities, :download
|
||||
end
|
||||
|
||||
scope "/settings", Settings do
|
||||
live "/account", Account
|
||||
|
||||
|
||||
@@ -50,9 +50,10 @@ defmodule Web.MixProject do
|
||||
{:gettext, "~> 0.20"},
|
||||
{:remote_ip, "~> 1.0"},
|
||||
|
||||
# CLDR
|
||||
# CLDR and unit conversions
|
||||
{:ex_cldr_dates_times, "~> 2.13"},
|
||||
{:ex_cldr_numbers, "~> 2.31"},
|
||||
{:sizeable, "~> 1.0"},
|
||||
|
||||
# Asset pipeline deps
|
||||
{:esbuild, "~> 0.7", runtime: Mix.env() == :dev},
|
||||
@@ -77,6 +78,7 @@ defmodule Web.MixProject do
|
||||
# Other deps
|
||||
{:jason, "~> 1.2"},
|
||||
{:file_size, "~> 3.0.1"},
|
||||
{:nimble_csv, "~> 1.2"},
|
||||
|
||||
# Test deps
|
||||
{:floki, ">= 0.30.0", only: :test},
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
|
||||
"mint": {:hex, :mint, "1.5.1", "8db5239e56738552d85af398798c80648db0e90f343c8469f6c6d8898944fb6f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4a63e1e76a7c3956abd2c72f370a0d0aecddc3976dea5c27eccbecfa5e7d5b1e"},
|
||||
"mix_audit": {:hex, :mix_audit, "2.1.1", "653aa6d8f291fc4b017aa82bdb79a4017903902ebba57960ef199cbbc8c008a1", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "541990c3ab3a7bb8c4aaa2ce2732a4ae160ad6237e5dcd5ad1564f4f85354db1"},
|
||||
"nimble_csv": {:hex, :nimble_csv, "1.2.0", "4e26385d260c61eba9d4412c71cea34421f296d5353f914afe3f2e71cce97722", [:mix], [], "hexpm", "d0628117fcc2148178b034044c55359b26966c6eaa8e2ce15777be3bbc91b12a"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"},
|
||||
"nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"},
|
||||
@@ -104,6 +105,7 @@
|
||||
"remote_ip": {:hex, :remote_ip, "1.1.0", "cb308841595d15df3f9073b7c39243a1dd6ca56e5020295cb012c76fbec50f2d", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "616ffdf66aaad6a72fc546dabf42eed87e2a99e97b09cbd92b10cc180d02ed74"},
|
||||
"rustler_precompiled": {:hex, :rustler_precompiled, "0.5.5", "a075a92c8e748ce5c4f7b2cf573a072d206a6d8d99c53f627e81d3f2b10616a3", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "e8a7f1abfec8d68683bb25d14efc88496f091ef113f7f4c45d39f3606f7223f6"},
|
||||
"samly": {:git, "https://github.com/firezone/samly.git", "4603438ed4a95ed74d6c0232676c24d097e2feec", []},
|
||||
"sizeable": {:hex, :sizeable, "1.0.2", "625fe06a5dad188b52121a140286f1a6ae1adf350a942cf419499ecd8a11ee29", [:mix], [], "hexpm", "4bab548e6dfba777b400ca50830a9e3a4128e73df77ab1582540cf5860601762"},
|
||||
"sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"},
|
||||
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
|
||||
"sweet_xml": {:hex, :sweet_xml, "0.7.3", "debb256781c75ff6a8c5cbf7981146312b66f044a2898f453709a53e5031b45b", [:mix], [], "hexpm", "e110c867a1b3fe74bfc7dd9893aa851f0eed5518d0d7cad76d7baafd30e4f5ba"},
|
||||
|
||||
Reference in New Issue
Block a user