Refactor Gateway Liveviews to use real data (#1760)

Why:

* The previous Gateway Liveviews had used static views and data as a
starting point for fleshing out the web UI. This commit builds on that
and replaces (most) of the static data with data from the database, as
well as updating the static Liveview templates to use components where
possible.

Note: These changes are only meant to involve the Gateway views
(index/show/edit). More changes to other resources will follow(i.e.
Resource, Users, Devices, etc...)

---------

Signed-off-by: bmanifold <bmanifold@users.noreply.github.com>
Co-authored-by: Andrew Dryga <andrew@dryga.com>
This commit is contained in:
bmanifold
2023-07-18 17:15:59 -04:00
committed by GitHub
parent 367c9f1456
commit 9a06a9bb14
16 changed files with 1180 additions and 893 deletions

View File

@@ -101,12 +101,18 @@ defmodule Domain.Gateways do
end
end
def fetch_gateway_by_id(id, %Auth.Subject{} = subject) do
def fetch_gateway_by_id(id, %Auth.Subject{} = subject, opts \\ []) do
{preload, _opts} = Keyword.pop(opts, :preload, [])
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()),
true <- Validator.valid_uuid?(id) do
Gateway.Query.by_id(id)
|> Authorizer.for_subject(subject)
|> Repo.fetch()
|> case do
{:ok, gateway} -> {:ok, Repo.preload(gateway, preload)}
{:error, reason} -> {:error, reason}
end
else
false -> {:error, :not_found}
other -> other
@@ -121,11 +127,16 @@ defmodule Domain.Gateways do
|> Repo.preload(preload)
end
def list_gateways(%Auth.Subject{} = subject) do
def list_gateways(%Auth.Subject{} = subject, opts \\ []) do
{preload, _opts} = Keyword.pop(opts, :preload, [])
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do
Gateway.Query.all()
|> Authorizer.for_subject(subject)
|> Repo.list()
{:ok, gateways} =
Gateway.Query.all()
|> Authorizer.for_subject(subject)
|> Repo.list()
{:ok, Repo.preload(gateways, preload)}
end
end

View File

@@ -1,5 +1,6 @@
defmodule Domain.Resources do
alias Domain.{Repo, Validator, Auth}
alias Domain.Gateways
alias Domain.Resources.{Authorizer, Resource}
def fetch_resource_by_id(id, %Auth.Subject{} = subject) do
@@ -46,6 +47,44 @@ defmodule Domain.Resources do
end
end
def count_resources_for_gateway(%Gateways.Gateway{} = gateway, %Auth.Subject{} = subject) do
required_permissions =
{:one_of,
[
Authorizer.manage_resources_permission(),
Authorizer.view_available_resources_permission()
]}
with :ok <- Auth.ensure_has_permissions(subject, required_permissions) do
count =
Resource.Query.all()
|> Authorizer.for_subject(subject)
|> Resource.Query.by_gateway_group_id(gateway.group_id)
|> Repo.aggregate(:count)
{:ok, count}
end
end
def list_resources_for_gateway(%Gateways.Gateway{} = gateway, %Auth.Subject{} = subject) do
required_permissions =
{:one_of,
[
Authorizer.manage_resources_permission(),
Authorizer.view_available_resources_permission()
]}
with :ok <- Auth.ensure_has_permissions(subject, required_permissions) do
resources =
Resource.Query.all()
|> Resource.Query.by_account_id(subject.account.id)
|> Resource.Query.by_gateway_group_id(gateway.group_id)
|> Repo.all()
{:ok, resources}
end
end
def create_resource(attrs, %Auth.Subject{} = subject) do
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_resources_permission()) do
changeset = Resource.Changeset.create_changeset(subject.account, attrs)

View File

@@ -13,4 +13,23 @@ defmodule Domain.Resources.Resource.Query do
def by_account_id(queryable \\ all(), account_id) do
where(queryable, [resources: resources], resources.account_id == ^account_id)
end
def by_gateway_group_id(queryable \\ all(), gateway_group_id) do
queryable
|> with_joined_connections()
|> where([connections: connections], connections.gateway_group_id == ^gateway_group_id)
end
def with_joined_connections(queryable \\ all()) do
with_named_binding(queryable, :connections, fn queryable, binding ->
queryable
|> join(
:inner,
[resources: resources],
connections in ^Domain.Resources.Connection.Query.all(),
on: connections.resource_id == resources.id,
as: ^binding
)
end)
end
end

View File

@@ -2,9 +2,9 @@ defimpl String.Chars, for: Postgrex.INET do
def to_string(%Postgrex.INET{} = inet), do: Domain.Types.INET.to_string(inet)
end
# defimpl Phoenix.HTML.Safe, for: Postgrex.INET do
# def to_iodata(%Postgrex.INET{} = inet), do: Domain.Types.INET.to_string(inet)
# end
defimpl Phoenix.HTML.Safe, for: Postgrex.INET do
def to_iodata(%Postgrex.INET{} = inet), do: Domain.Types.INET.to_string(inet)
end
defimpl Jason.Encoder, for: Postgrex.INET do
def encode(%Postgrex.INET{} = struct, opts) do
@@ -16,6 +16,6 @@ defimpl String.Chars, for: Domain.Types.IPPort do
def to_string(%Domain.Types.IPPort{} = ip_port), do: Domain.Types.IPPort.to_string(ip_port)
end
# defimpl Phoenix.HTML.Safe, for: Domain.Types.IPPort do
# def to_iodata(%Domain.Types.IPPort{} = ip_port), do: Domain.Types.IPPort.to_string(ip_port)
# end
defimpl Phoenix.HTML.Safe, for: Domain.Types.IPPort do
def to_iodata(%Domain.Types.IPPort{} = ip_port), do: Domain.Types.IPPort.to_string(ip_port)
end

View File

@@ -171,7 +171,7 @@ IO.puts("Created gateway groups:")
IO.puts(" #{gateway_group.name_prefix} token: #{Gateways.encode_token!(gateway_group_token)}")
IO.puts("")
{:ok, gateway} =
{:ok, gateway1} =
Gateways.upsert_gateway(gateway_group_token, %{
external_id: Ecto.UUID.generate(),
name_suffix: "gw-#{Domain.Crypto.rand_string(5)}",
@@ -180,12 +180,28 @@ IO.puts("")
last_seen_remote_ip: %Postgrex.INET{address: {189, 172, 73, 153}}
})
{:ok, gateway2} =
Gateways.upsert_gateway(gateway_group_token, %{
external_id: Ecto.UUID.generate(),
name_suffix: "gw-#{Domain.Crypto.rand_string(5)}",
public_key: :crypto.strong_rand_bytes(32) |> Base.encode64(),
last_seen_user_agent: "iOS/12.7 (iPhone) connlib/0.7.412",
last_seen_remote_ip: %Postgrex.INET{address: {164, 112, 78, 62}}
})
IO.puts("Created gateways:")
gateway_name = "#{gateway_group.name_prefix}-#{gateway.name_suffix}"
gateway_name = "#{gateway_group.name_prefix}-#{gateway1.name_suffix}"
IO.puts(" #{gateway_name}:")
IO.puts(" External UUID: #{gateway.external_id}")
IO.puts(" Public Key: #{gateway.public_key}")
IO.puts(" IPv4: #{gateway.ipv4} IPv6: #{gateway.ipv6}")
IO.puts(" External UUID: #{gateway1.external_id}")
IO.puts(" Public Key: #{gateway1.public_key}")
IO.puts(" IPv4: #{gateway1.ipv4} IPv6: #{gateway1.ipv6}")
IO.puts("")
gateway_name = "#{gateway_group.name_prefix}-#{gateway2.name_suffix}"
IO.puts(" #{gateway_name}:")
IO.puts(" External UUID: #{gateway1.external_id}")
IO.puts(" Public Key: #{gateway2.public_key}")
IO.puts(" IPv4: #{gateway2.ipv4} IPv6: #{gateway2.ipv6}")
IO.puts("")
{:ok, dns_resource} =

View File

@@ -400,6 +400,15 @@ defmodule Domain.GatewaysTest do
{:unauthorized,
[missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}}
end
# TODO: add a test that soft-deleted assocs are not preloaded
test "associations are preloaded when opts given", %{account: account, subject: subject} do
gateway = GatewaysFixtures.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
end
end
describe "list_gateways/1" do
@@ -438,6 +447,18 @@ defmodule Domain.GatewaysTest do
{:unauthorized,
[missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}}
end
# TODO: add a test that soft-deleted assocs are not preloaded
test "associations are preloaded when opts given", %{account: account, subject: subject} do
GatewaysFixtures.create_gateway(account: account)
GatewaysFixtures.create_gateway(account: account)
{:ok, gateways} = list_gateways(subject, preload: [:group, :account])
assert length(gateways) == 2
assert Enum.all?(gateways, fn g -> Ecto.assoc_loaded?(g.group) end) == true
assert Enum.all?(gateways, fn g -> Ecto.assoc_loaded?(g.account) end) == true
end
end
describe "list_connected_gateways_for_resource/1" do

View File

@@ -29,18 +29,15 @@ defmodule Domain.ResourcesTest do
end
test "returns resource when resource exists", %{account: account, subject: subject} do
gateway = GatewaysFixtures.create_gateway(account: account)
resource = ResourcesFixtures.create_resource(account: account, gateway: gateway)
resource = ResourcesFixtures.create_resource(account: account)
assert {:ok, fetched_resource} = fetch_resource_by_id(resource.id, subject)
assert fetched_resource.id == resource.id
end
test "does not return deleted resources", %{account: account, subject: subject} do
gateway = GatewaysFixtures.create_gateway(account: account)
{:ok, resource} =
ResourcesFixtures.create_resource(account: account, gateway: gateway)
ResourcesFixtures.create_resource(account: account)
|> delete_resource(subject)
assert fetch_resource_by_id(resource.id, subject) == {:error, :not_found}
@@ -138,6 +135,172 @@ defmodule Domain.ResourcesTest do
end
end
describe "list_resources_for_gateway/2" do
test "returns empty list when there are no resources associated to gateway", %{
account: account,
subject: subject
} do
gateway = GatewaysFixtures.create_gateway(account: account)
assert list_resources_for_gateway(gateway, subject) == {:ok, []}
end
test "does not list resources that are not associated to the gateway", %{
account: account,
subject: subject
} do
gateway = GatewaysFixtures.create_gateway(account: account)
ResourcesFixtures.create_resource()
assert list_resources_for_gateway(gateway, subject) == {:ok, []}
end
test "does not list deleted resources associated to gateway", %{
account: account,
subject: subject
} do
group = GatewaysFixtures.create_group(account: account, subject: subject)
gateway = GatewaysFixtures.create_gateway(account: account, group: group)
ResourcesFixtures.create_resource(
account: account,
gateway_groups: [%{gateway_group_id: group.id}]
)
|> delete_resource(subject)
assert list_resources_for_gateway(gateway, subject) == {:ok, []}
end
test "returns all resources for a given gateway and account user subject", %{
account: account,
subject: subject
} do
group = GatewaysFixtures.create_group(account: account, subject: subject)
gateway = GatewaysFixtures.create_gateway(account: account, group: group)
ResourcesFixtures.create_resource(
account: account,
gateway_groups: [%{gateway_group_id: group.id}]
)
ResourcesFixtures.create_resource(
account: account,
gateway_groups: [%{gateway_group_id: group.id}]
)
ResourcesFixtures.create_resource(account: account)
assert {:ok, resources} = list_resources_for_gateway(gateway, subject)
assert length(resources) == 2
end
test "returns error when subject has no permission to manage resources", %{
account: account,
subject: subject
} do
group = GatewaysFixtures.create_group(account: account, subject: subject)
gateway = GatewaysFixtures.create_gateway(account: account, group: group)
ResourcesFixtures.create_resource(
account: account,
gateway_groups: [%{gateway_group_id: group.id}]
)
subject = AuthFixtures.remove_permissions(subject)
assert list_resources_for_gateway(gateway, subject) ==
{:error,
{:unauthorized,
[
missing_permissions: [
{:one_of,
[
Resources.Authorizer.manage_resources_permission(),
Resources.Authorizer.view_available_resources_permission()
]}
]
]}}
end
end
describe "count_resources_for_gateway/2" do
test "returns zero when there are no resources associated to gateway", %{
account: account,
subject: subject
} do
gateway = GatewaysFixtures.create_gateway(account: account)
assert count_resources_for_gateway(gateway, subject) == {:ok, 0}
end
test "does not count resources that are not associated to the gateway", %{
account: account,
subject: subject
} do
group = GatewaysFixtures.create_group(account: account, subject: subject)
gateway = GatewaysFixtures.create_gateway(account: account, group: group)
ResourcesFixtures.create_resource(
account: account,
gateway_groups: [%{gateway_group_id: group.id}]
)
ResourcesFixtures.create_resource(account: account)
assert count_resources_for_gateway(gateway, subject) == {:ok, 1}
end
test "does not count deleted resources associated to gateway", %{
account: account,
subject: subject
} do
group = GatewaysFixtures.create_group(account: account, subject: subject)
gateway = GatewaysFixtures.create_gateway(account: account, group: group)
ResourcesFixtures.create_resource(
account: account,
gateway_groups: [%{gateway_group_id: group.id}]
)
ResourcesFixtures.create_resource(
account: account,
gateway_groups: [%{gateway_group_id: group.id}]
)
|> delete_resource(subject)
assert count_resources_for_gateway(gateway, subject) == {:ok, 1}
end
test "returns error when subject has no permission to manage resources",
%{
account: account,
subject: subject
} do
group = GatewaysFixtures.create_group(account: account, subject: subject)
gateway = GatewaysFixtures.create_gateway(account: account, group: group)
ResourcesFixtures.create_resource(
account: account,
gateway_groups: [%{gateway_group_id: group.id}]
)
subject = AuthFixtures.remove_permissions(subject)
assert count_resources_for_gateway(gateway, subject) ==
{:error,
{:unauthorized,
[
missing_permissions: [
{:one_of,
[
Resources.Authorizer.manage_resources_permission(),
Resources.Authorizer.view_available_resources_permission()
]}
]
]}}
end
end
describe "create_resource/2" do
test "returns changeset error on empty attrs", %{subject: subject} do
assert {:error, changeset} = create_resource(%{}, subject)

View File

@@ -6,6 +6,54 @@ 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.
// primary: orange
"heat-wave": {
50: "#fff9f5",
100: "#fff1e5",
200: "#ffddc2",
300: "#ffbc85",
400: "#ff9a47",
450: "#ff7300",
500: "#ff7605",
600: "#c25700",
700: "#7f3900",
800: "#5c2900",
900: "#331700",
},
// accent: violet
"electric-violet": {
50: "#f8f5ff",
100: "#ece5ff",
200: "#d2c2ff",
300: "#a585ff",
400: "#7847ff",
450: "#5e00d6",
500: "#4805ff",
600: "#3400c2",
700: "#37007f",
800: "#28005c",
900: "#160033",
},
// neutral: night-rider
"night-rider": {
50: "#fcfcfc",
100: "#f8f7f7",
200: "#ebebea",
300: "#dfdedd",
400: "#c7c4c2",
500: "#a7a3a0",
600: "#90867f",
700: "#766a60",
800: "#4c3e33",
900: "#1b140e",
},
};
module.exports = {
// Use "media" to synchronize dark mode with the OS, "class" to require manual toggle
darkMode: "class",
@@ -22,17 +70,21 @@ module.exports = {
extend: {
colors: {
brand: "#FD4F00",
primary: {
"50": "#eff6ff",
"100": "#dbeafe",
"200": "#bfdbfe",
"300": "#93c5fd",
"400": "#60a5fa",
"500": "#3b82f6",
"600": "#2563eb",
"700": "#1d4ed8",
"800": "#1e40af", "900": "#1e3a8a"
}
primary: firezoneColors["heat-wave"],
accent: firezoneColors["electric-violet"],
neutral: firezoneColors["night-rider"]
//primary: {
// "50": "#eff6ff",
// "100": "#dbeafe",
// "200": "#bfdbfe",
// "300": "#93c5fd",
// "400": "#60a5fa",
// "500": "#3b82f6",
// "600": "#2563eb",
// "700": "#1d4ed8",
// "800": "#1e40af",
// "900": "#1e3a8a"
//}
}
},
},

View File

@@ -99,6 +99,8 @@ defmodule Web do
import Phoenix.HTML
# Core UI components and translation
import Web.CoreComponents
import Web.FormComponents
import Web.TableComponents
import Web.Gettext
# Shortcut for generating JS commands

View File

@@ -94,7 +94,10 @@ defmodule Web.CoreComponents do
<%= for tab <- @tab do %>
<li class="mr-2" role="presentation">
<button
class="inline-block p-4 border-b-2 border-transparent rounded-t-lg hover:text-gray-600 hover:border-gray-300 dark:hover:text-gray-300"
class={~w[
inline-block p-4 border-b-2 border-transparent rounded-t-lg
hover:text-gray-600 hover:border-gray-300 dark:hover:text-gray-300
]}
id={"#{tab.id}-tab"}
data-tabs-target={"##{tab.id}"}
type="button"
@@ -177,126 +180,39 @@ defmodule Web.CoreComponents do
def button_group(assigns) do
~H"""
<div class="inline-flex rounded-md shadow-sm" role="group">
<button
type="button"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-l-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500 dark:focus:text-white"
>
<button type="button" class={~w[
px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200
rounded-l-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2
focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600
dark:text-white dark:hover:text-white dark:hover:bg-gray-600
dark:focus:ring-blue-500 dark:focus:text-white
]}>
<%= render_slot(@first) %>
</button>
<%= for middle <- @middle do %>
<button
type="button"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border-t border-b border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500 dark:focus:text-white"
>
<button type="button" class={~w[
px-4 py-2 text-sm font-medium text-gray-900 bg-white border-t border-b
border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2
focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600
dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500
dark:focus:text-white
]}>
<%= render_slot(middle) %>
</button>
<% end %>
<button
type="button"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-r-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500 dark:focus:text-white"
>
<button type="button" class={~w[
px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200
rounded-r-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2
focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600
dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500
dark:focus:text-white
]}>
<%= render_slot(@last) %>
</button>
</div>
"""
end
@doc """
Render a submit button.
## Examples
<.submit_button>
Save
</.submit_button>
"""
slot :inner_block, required: true
def submit_button(assigns) do
~H"""
<button
type="submit"
class="inline-flex items-center px-5 py-2.5 mt-4 sm:mt-6 text-sm font-medium text-center text-white bg-primary-700 rounded-lg focus:ring-4 focus:ring-primary-200 dark:focus:ring-primary-900 hover:bg-primary-800"
>
<%= render_slot(@inner_block) %>
</button>
"""
end
@doc """
Render a delete button.
## Examples
<.delete_button path={Routes.user_path(@conn, :edit, @user.id)}/>
Edit user
</.delete_button>
"""
attr :phx_click, :string, doc: "Action to perform when the button is clicked"
slot :inner_block, required: true
def delete_button(assigns) do
~H"""
<button
type="button"
class="text-red-600 inline-flex items-center hover:text-white border border-red-600 hover:bg-red-600 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:border-red-500 dark:text-red-500 dark:hover:text-white dark:hover:bg-red-600 dark:focus:ring-red-900"
>
<!-- XXX: Fix icon for dark mode -->
<!-- <.icon name="hero-trash-solid" class="text-red-600 w-5 h-5 mr-1 -ml-1" /> -->
<%= render_slot(@inner_block) %>
</button>
"""
end
@doc """
Renders an add button.
## Examples
<.add_button navigate={~p"/users/new"}>
Add user
</.add_button>
"""
attr :navigate, :any, required: true, doc: "Path to navigate to"
slot :inner_block, required: true
def add_button(assigns) do
~H"""
<.link
navigate={@navigate}
class="flex items-center justify-center text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-primary-600 dark:hover:bg-primary-700 focus:outline-none dark:focus:ring-primary-800"
>
<.icon name="hero-plus" class="h-3.5 w-3.5 mr-2" />
<%= render_slot(@inner_block) %>
</.link>
"""
end
@doc """
Renders an edit button.
## Examples
<.edit_button path={Routes.user_path(@conn, :edit, @user.id)}/>
Edit user
</.edit_button>
"""
attr :navigate, :any, required: true, doc: "Path to navigate to"
slot :inner_block, required: true
def edit_button(assigns) do
~H"""
<.link
navigate={@navigate}
class="flex items-center justify-center text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-primary-600 dark:hover:bg-primary-700 focus:outline-none dark:focus:ring-primary-800"
>
<.icon name="hero-pencil-solid" class="h-3.5 w-3.5 mr-2" />
<%= render_slot(@inner_block) %>
</.link>
"""
end
@doc """
Renders a paginator bar.
@@ -325,60 +241,66 @@ defmodule Web.CoreComponents do
</span>
<ul class="inline-flex items-stretch -space-x-px">
<li>
<a
href="#"
class="flex items-center justify-center h-full py-1.5 px-3 ml-0 text-gray-500 bg-white rounded-l-lg border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
>
<a href="#" class={~w[
flex items-center justify-center h-full py-1.5 px-3 ml-0 text-gray-500 bg-white rounded-l-lg
border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700
dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white
]}>
<span class="sr-only">Previous</span>
<.icon name="hero-chevron-left" class="w-5 h-5" />
</a>
</li>
<li>
<a
href="#"
class="flex items-center justify-center text-sm py-2 px-3 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
>
<a href="#" class={~w[
flex items-center justify-center text-sm py-2 px-3 leading-tight text-gray-500 bg-white border
border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700
dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white
]}>
1
</a>
</li>
<li>
<a
href="#"
class="flex items-center justify-center text-sm py-2 px-3 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
>
<a href="#" class={~w[
flex items-center justify-center text-sm py-2 px-3 leading-tight text-gray-500 bg-white
border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700
dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white
]}>
2
</a>
</li>
<li>
<a
href="#"
aria-current="page"
class="flex items-center justify-center text-sm z-10 py-2 px-3 leading-tight text-primary-600 bg-primary-50 border border-primary-300 hover:bg-primary-100 hover:text-primary-700 dark:border-gray-700 dark:bg-gray-700 dark:text-white"
>
<a href="#" aria-current="page" class={~w[
flex items-center justify-center text-sm z-10 py-2 px-3 leading-tight text-primary-600 bg-primary-50
border border-primary-300 hover:bg-primary-100 hover:text-primary-700 dark:border-gray-700
dark:bg-gray-700 dark:text-white
]}>
<%= @page %>
</a>
</li>
<li>
<a
href="#"
class="flex items-center justify-center text-sm py-2 px-3 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
>
<a href="#" class={~w[
flex items-center justify-center text-sm py-2 px-3 leading-tight text-gray-500 bg-white
border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700
dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white
]}>
...
</a>
</li>
<li>
<a
href="#"
class="flex items-center justify-center text-sm py-2 px-3 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
>
<a href="#" class={~w[
flex items-center justify-center text-sm py-2 px-3 leading-tight text-gray-500 bg-white
border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700
dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white
]}>
<%= @total_pages %>
</a>
</li>
<li>
<a
href="#"
class="flex items-center justify-center h-full py-1.5 px-3 leading-tight text-gray-500 bg-white rounded-r-lg border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
>
<a href="#" class={~w[
flex items-center justify-center h-full py-1.5 px-3 leading-tight text-gray-500 bg-white rounded-r-lg
border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700
dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white
]}>
<span class="sr-only">Next</span>
<.icon name="hero-chevron-right" class="w-5 h-5" />
</a>
@@ -564,227 +486,6 @@ defmodule Web.CoreComponents do
"""
end
@doc """
Renders a simple form.
## Examples
<.simple_form for={@form} phx-change="validate" phx-submit="save">
<.input field={@form[:email]} label="Email"/>
<.input field={@form[:username]} label="Username" />
<:actions>
<.button>Save</.button>
</:actions>
</.simple_form>
"""
attr :for, :any, required: true, doc: "the datastructure for the form"
attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
attr :rest, :global,
include: ~w(autocomplete name rel action enctype method novalidate target),
doc: "the arbitrary HTML attributes to apply to the form tag"
slot :inner_block, required: true
slot :actions, doc: "the slot for form actions, such as a submit button"
def simple_form(assigns) do
~H"""
<.form :let={f} for={@for} as={@as} {@rest}>
<div class="space-y-8 bg-white">
<%= render_slot(@inner_block, f) %>
<div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
<%= render_slot(action, f) %>
</div>
</div>
</.form>
"""
end
@doc """
Renders a button.
## Examples
<.button>Send!</.button>
<.button phx-click="go" class="ml-2">Send!</.button>
"""
attr :type, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global, include: ~w(disabled form name value)
slot :inner_block, required: true
def button(assigns) do
~H"""
<button
type={@type}
class={[
"phx-submit-loading:opacity-75",
"text-white bg-primary-600 font-medium rounded-lg text-sm px-5 py-2.5 text-center",
"hover:bg-primary-700",
"focus:ring-4 focus:outline-none focus:ring-primary-300",
"dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800",
"active:text-white/80",
@class
]}
{@rest}
>
<%= render_slot(@inner_block) %>
</button>
"""
end
@doc """
Renders an input with label and error messages.
A `%Phoenix.HTML.Form{}` and field name may be passed to the input
to build input names and error messages, or all the attributes and
errors may be passed explicitly.
## Examples
<.input field={@form[:email]} type="email" />
<.input name="my-input" errors={["oh no!"]} />
"""
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file hidden month number password
range radio search select tel text textarea time url week)
attr :field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox and radio inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :rest, :global,
include: ~w(autocomplete cols disabled form list max maxlength min minlength
pattern placeholder readonly required rows size step)
slot :inner_block
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(field.errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "radio"} = assigns) do
~H"""
<div phx-feedback-for={@name}>
<label class="flex items-center gap-2 text-gray-900 dark:text-gray-300">
<input
type="radio"
id={@id}
name={@name}
value={@value}
checked={@checked}
class="w-4 h-4 border-gray-300 focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-600 dark:focus:bg-blue-600 dark:bg-gray-700 dark:border-gray-600"
{@rest}
/>
<%= @label %>
</label>
</div>
"""
end
def input(%{type: "checkbox", value: value} = assigns) do
assigns =
assign_new(assigns, :checked, fn -> Phoenix.HTML.Form.normalize_value("checkbox", value) end)
~H"""
<div phx-feedback-for={@name}>
<label class="flex items-center gap-4 text-sm leading-6 text-zinc-600">
<input type="hidden" name={@name} value="false" />
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class="rounded border-zinc-300 text-zinc-900 focus:ring-0"
{@rest}
/>
<%= @label %>
</label>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}><%= @label %></.label>
<select
id={@id}
name={@name}
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value=""><%= @prompt %></option>
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
</select>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}><%= @label %></.label>
<textarea
id={@id}
name={@name}
class={[
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
"phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400",
"min-h-[6rem] border-zinc-300 focus:border-zinc-400",
@errors != [] && "border-rose-400 focus:border-rose-400"
]}
{@rest}
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}><%= @label %></.label>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
"phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400",
"border-zinc-300 focus:border-zinc-400",
@errors != [] && "border-rose-400 focus:border-rose-400"
]}
{@rest}
/>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
@doc """
Renders a standard form label.
"""
@@ -838,82 +539,6 @@ defmodule Web.CoreComponents do
"""
end
@doc ~S"""
Renders a table with generic styling.
## Examples
<.table id="users" rows={@users}>
<:col :let={user} label="id"><%= user.id %></:col>
<:col :let={user} label="username"><%= user.username %></:col>
</.table>
"""
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
slot :col, required: true do
attr :label, :string
end
slot :action, doc: "the slot for showing user actions in the last table column"
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<div class="overflow-y-auto px-4 sm:overflow-visible sm:px-0">
<table class="w-[40rem] mt-11 sm:w-full">
<thead class="text-sm text-left leading-6 text-zinc-500">
<tr>
<th :for={col <- @col} class="p-0 pr-6 pb-4 font-normal"><%= col[:label] %></th>
<th class="relative p-0 pb-4"><span class="sr-only"><%= gettext("Actions") %></span></th>
</tr>
</thead>
<tbody
id={@id}
phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700"
>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="group hover:bg-zinc-50">
<td
:for={{col, i} <- Enum.with_index(@col)}
phx-click={@row_click && @row_click.(row)}
class={["relative p-0", @row_click && "hover:cursor-pointer"]}
>
<div class="block py-4 pr-6">
<span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
<span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
<%= render_slot(col, @row_item.(row)) %>
</span>
</div>
</td>
<td :if={@action != []} class="relative w-14 p-0">
<div class="relative whitespace-nowrap py-4 text-right text-sm font-medium">
<span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" />
<span
:for={action <- @action}
class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
>
<%= render_slot(action, @row_item.(row)) %>
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
"""
end
@doc """
Renders a data list.
@@ -1066,6 +691,27 @@ defmodule Web.CoreComponents do
"""
end
attr :type, :string, default: "default"
slot :inner_block, required: true
def badge(assigns) do
colors = %{
"success" => "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
"danger" => "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300",
"warning" => "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300",
"info" => "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
"default" => "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300"
}
assigns = assign(assigns, colors: colors)
~H"""
<span class={"text-xs font-medium mr-2 px-2.5 py-0.5 rounded #{@colors[@type]}"}>
<%= render_slot(@inner_block) %>
</span>
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do
@@ -1140,4 +786,38 @@ defmodule Web.CoreComponents do
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
@doc """
Returns a string the represents a relative time for a given Datetime
from the current time or a given base time
"""
attr :relative, DateTime, required: true
attr :relative_to, DateTime, required: false, default: DateTime.utc_now()
def relative_datetime(assigns) do
# Note: This code was written with the intent to be replace by the following in the future:
# https://github.com/elixir-cldr/cldr_dates_times/blob/main/lib/cldr/datetime/relative.ex
diff = DateTime.diff(assigns[:relative_to], assigns[:relative])
diff_str =
cond do
diff <= -24 * 3600 -> "in #{div(-diff, 24 * 3600)}day(s)"
diff <= -3600 -> "in #{div(-diff, 3600)}hour(s)"
diff <= -60 -> "in #{div(-diff, 60)}minute(s)"
diff <= -5 -> "in #{-diff}seconds"
diff <= 5 -> "now"
diff <= 60 -> "#{diff}seconds ago"
diff <= 3600 -> "#{div(diff, 60)} minute(s) ago"
diff <= 24 * 3600 -> "#{div(diff, 3600)} hour(s) ago"
true -> "#{div(diff, 24 * 3600)} day(s) ago"
end
assigns = assign(assigns, diff_str: diff_str)
~H"""
<span>
<%= @diff_str %>
</span>
"""
end
end

View File

@@ -0,0 +1,333 @@
defmodule Web.FormComponents do
@moduledoc """
Provides Form UI components.
"""
use Phoenix.Component
use Web, :verified_routes
import Web.CoreComponents, only: [icon: 1, error: 1, label: 1, translate_error: 1]
### Inputs ###
@doc """
Renders an input with label and error messages.
A `%Phoenix.HTML.Form{}` and field name may be passed to the input
to build input names and error messages, or all the attributes and
errors may be passed explicitly.
## Examples
<.input field={@form[:email]} type="email" />
<.input name="my-input" errors={["oh no!"]} />
"""
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file hidden month number password
range radio search select tel text textarea time url week)
attr :field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox and radio inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :rest, :global,
include: ~w(autocomplete cols disabled form list max maxlength min minlength
pattern placeholder readonly required rows size step)
slot :inner_block
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(field.errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "radio"} = assigns) do
~H"""
<div phx-feedback-for={@name}>
<label class="flex items-center gap-2 text-gray-900 dark:text-gray-300">
<input
type="radio"
id={@id}
name={@name}
value={@value}
checked={@checked}
class="w-4 h-4 border-gray-300 focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-600 dark:focus:bg-blue-600 dark:bg-gray-700 dark:border-gray-600"
{@rest}
/>
<%= @label %>
</label>
</div>
"""
end
def input(%{type: "checkbox", value: value} = assigns) do
assigns =
assign_new(assigns, :checked, fn -> Phoenix.HTML.Form.normalize_value("checkbox", value) end)
~H"""
<div phx-feedback-for={@name}>
<label class="flex items-center gap-4 text-sm leading-6 text-zinc-600">
<input type="hidden" name={@name} value="false" />
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class="rounded border-zinc-300 text-zinc-900 focus:ring-0"
{@rest}
/>
<%= @label %>
</label>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}><%= @label %></.label>
<select
id={@id}
name={@name}
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value=""><%= @prompt %></option>
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
</select>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}><%= @label %></.label>
<textarea
id={@id}
name={@name}
class={[
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
"phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400",
"min-h-[6rem] border-zinc-300 focus:border-zinc-400",
@errors != [] && "border-rose-400 focus:border-rose-400"
]}
{@rest}
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}><%= @label %></.label>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
"phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400",
"border-zinc-300 focus:border-zinc-400",
@errors != [] && "border-rose-400 focus:border-rose-400"
]}
{@rest}
/>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
### Buttons ###
@doc """
Renders a button.
## Examples
<.button>Send!</.button>
<.button phx-click="go" class="ml-2">Send!</.button>
"""
attr :type, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global, include: ~w(disabled form name value)
slot :inner_block, required: true
def button(assigns) do
~H"""
<button
type={@type}
class={[
"phx-submit-loading:opacity-75",
"text-white bg-primary-600 font-medium rounded-lg text-sm px-5 py-2.5 text-center",
"hover:bg-primary-700",
"focus:ring-4 focus:outline-none focus:ring-primary-300",
"dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800",
"active:text-white/80",
@class
]}
{@rest}
>
<%= render_slot(@inner_block) %>
</button>
"""
end
@doc """
Render a submit button.
## Examples
<.submit_button>
Save
</.submit_button>
"""
slot :inner_block, required: true
def submit_button(assigns) do
~H"""
<button type="submit" class={~w[
inline-flex items-center px-5 py-2.5 mt-4 sm:mt-6 text-sm font-medium text-center text-white
bg-primary-700 rounded-lg focus:ring-4 focus:ring-primary-200 dark:focus:ring-primary-900
hover:bg-primary-800
]}>
<%= render_slot(@inner_block) %>
</button>
"""
end
@doc """
Render a delete button.
## Examples
<.delete_button path={Routes.user_path(@conn, :edit, @user.id)}/>
Edit user
</.delete_button>
"""
attr :phx_click, :string, doc: "Action to perform when the button is clicked"
slot :inner_block, required: true
def delete_button(assigns) do
~H"""
<button
type="button"
class="text-red-600 inline-flex items-center hover:text-white border border-red-600 hover:bg-red-600 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:border-red-500 dark:text-red-500 dark:hover:text-white dark:hover:bg-red-600 dark:focus:ring-red-900"
>
<!-- XXX: Fix icon for dark mode -->
<!-- <.icon name="hero-trash-solid" class="text-red-600 w-5 h-5 mr-1 -ml-1" /> -->
<%= render_slot(@inner_block) %>
</button>
"""
end
@doc """
Renders an add button.
## Examples
<.add_button navigate={~p"/users/new"}>
Add user
</.add_button>
"""
attr :navigate, :any, required: true, doc: "Path to navigate to"
slot :inner_block, required: true
def add_button(assigns) do
~H"""
<.link navigate={@navigate} class={~w[
flex items-center justify-center text-white bg-primary-500 hover:bg-primary-600
focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-4 py-2
dark:bg-primary-600 dark:hover:bg-primary-700 focus:outline-none dark:focus:ring-primary-800
]}>
<.icon name="hero-plus" class="h-3.5 w-3.5 mr-2" />
<%= render_slot(@inner_block) %>
</.link>
"""
end
@doc """
Renders an edit button.
## Examples
<.edit_button path={Routes.user_path(@conn, :edit, @user.id)}/>
Edit user
</.edit_button>
"""
attr :navigate, :any, required: true, doc: "Path to navigate to"
slot :inner_block, required: true
def edit_button(assigns) do
~H"""
<.link
navigate={@navigate}
class="flex items-center justify-center text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-primary-600 dark:hover:bg-primary-700 focus:outline-none dark:focus:ring-primary-800"
>
<.icon name="hero-pencil-solid" class="h-3.5 w-3.5 mr-2" />
<%= render_slot(@inner_block) %>
</.link>
"""
end
### Forms ###
@doc """
Renders a simple form.
## Examples
<.simple_form for={@form} phx-change="validate" phx-submit="save">
<.input field={@form[:email]} label="Email"/>
<.input field={@form[:username]} label="Username" />
<:actions>
<.button>Save</.button>
</:actions>
</.simple_form>
"""
attr :for, :any, required: true, doc: "the datastructure for the form"
attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
attr :rest, :global,
include: ~w(autocomplete name rel action enctype method novalidate target),
doc: "the arbitrary HTML attributes to apply to the form tag"
slot :inner_block, required: true
slot :actions, doc: "the slot for form actions, such as a submit button"
def simple_form(assigns) do
~H"""
<.form :let={f} for={@for} as={@as} {@rest}>
<div class="space-y-8 bg-white">
<%= render_slot(@inner_block, f) %>
<div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
<%= render_slot(action, f) %>
</div>
</div>
</.form>
"""
end
end

View File

@@ -0,0 +1,200 @@
defmodule Web.TableComponents do
@moduledoc """
Provides Table UI components.
"""
use Phoenix.Component
use Web, :verified_routes
import Web.Gettext
import Web.CoreComponents
@doc ~S"""
Renders a table with generic styling.
## Examples
<.table id="users" rows={@users}>
<:col :let={user} label="id"><%= user.id %></:col>
<:col :let={user} label="username"><%= user.username %></:col>
</.table>
"""
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
slot :col, required: true do
attr :label, :string
attr :sortable, :string
end
slot :action, doc: "the slot for showing user actions in the last table column"
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th :for={col <- @col} class="px-4 py-3">
<%= col[:label] %>
<.icon
:if={col[:sortable] == "true"}
name="hero-chevron-up-down-solid"
class="w-4 h-4 ml-1"
/>
</th>
<th class="px-4 py-3">
<span class="sr-only"><%= gettext("Actions") %></span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="border-b dark:border-gray-700">
<td
:for={{col, _i} <- Enum.with_index(@col)}
phx-click={@row_click && @row_click.(row)}
class={[
"px-4 py-3",
@row_click && "hover:cursor-pointer"
]}
>
<%= render_slot(col, @row_item.(row)) %>
</td>
<td :if={@action != []} class="px-4 py-3 flex items-center justify-end">
<button
id={"#{@row_id.(row)}-dropdown-button"}
data-dropdown-toggle={"#{@row_id.(row)}-dropdown"}
class={~w[
inline-flex items-center p-0.5 text-sm font-medium text-center
text-gray-500 hover:text-gray-800 rounded-lg focus:outline-none
dark:text-gray-400 dark:hover:text-gray-100
]}
type="button"
>
<.icon name="hero-ellipsis-horizontal" class="w-5 h-5" />
</button>
<div id={"#{@row_id.(row)}-dropdown" } class={~w[
hidden z-10 w-44 bg-white rounded divide-y divide-gray-100
shadow border border-gray-300 dark:bg-gray-700 dark:divide-gray-600"
]}>
<ul
class="py-1 text-sm text-gray-700 dark:text-gray-200"
aria-labelledby={"#{@row_id.(row)}-dropdown-button"}
>
<li :for={action <- @action}>
<%= render_slot(action, @row_item.(row)) %>
</li>
</ul>
</div>
</td>
</tr>
</tbody>
</table>
</div>
"""
end
@doc ~S"""
Renders a table with groups and generic styling.
The component is expecting the rows data to be in the form of a list
of tuples, where the first element of a given tuple is the group and
the second element of the tuple is a list of elements under that group
## Examples
<.table_with_groups id="users" rows={@grouped_users}>
<:col label="user group"></:col>
<:col :let={user} label="id"><%= user.id %></:col>
<:col :let={user} label="username"><%= user.username %></:col>
</.table>
"""
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
slot :col, required: true do
attr :label, :string
attr :sortable, :string
end
slot :action, doc: "the slot for showing user actions in the last table column"
def table_with_groups(assigns) do
~H"""
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th :for={col <- @col} class="px-4 py-3">
<%= col[:label] %>
<.icon
:if={col[:sortable] == "true"}
name="hero-chevron-up-down-solid"
class="w-4 h-4 ml-1"
/>
</th>
<th class="px-4 py-3">
<span class="sr-only"><%= gettext("Actions") %></span>
</th>
</tr>
</thead>
<tbody>
<%= for {group, items} <- @rows do %>
<tr class="bg-neutral-300">
<td class="px-4 py-2">
<%= group.name_prefix %>
</td>
<td colspan={length(@col)}></td>
</tr>
<tr :for={item <- items} class="border-b dark:border-gray-700">
<td :for={col <- @col} class="px-4 py-3">
<%= render_slot(col, item) %>
</td>
<td :if={@action != []} class="px-4 py-3 flex items-center justify-end">
<button
id={"#{@row_id.(item)}-dropdown-button"}
data-dropdown-toggle={"#{@row_id.(item)}-dropdown"}
class={~w[
inline-flex items-center p-0.5 text-sm font-medium text-center
text-gray-500 hover:text-gray-800 rounded-lg focus:outline-none
dark:text-gray-400 dark:hover:text-gray-100
]}
type="button"
>
<.icon name="hero-ellipsis-horizontal" class="w-5 h-5" />
</button>
<div id={"#{@row_id.(item)}-dropdown" } class={~w[
hidden z-10 w-44 bg-white rounded divide-y divide-gray-100
shadow border border-gray-300 dark:bg-gray-700 dark:divide-gray-600"
]}>
<ul
class="py-1 text-sm text-gray-700 dark:text-gray-200"
aria-labelledby={"#{@row_id.(item)}-dropdown-button"}
>
<li :for={action <- @action}>
<%= render_slot(action, @row_item.(item)) %>
</li>
</ul>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
"""
end
end

View File

@@ -1,6 +1,13 @@
defmodule Web.GatewaysLive.Edit do
use Web, :live_view
alias Domain.Gateways
def mount(%{"id" => id} = _params, _session, socket) do
{:ok, gateway} = Gateways.fetch_gateway_by_id(id, socket.assigns.subject, preload: :group)
{:ok, assign(socket, gateway: gateway)}
end
def render(assigns) do
~H"""
<.section_header>
@@ -9,17 +16,17 @@ defmodule Web.GatewaysLive.Edit do
%{label: "Home", path: ~p"/#{@subject.account}/dashboard"},
%{label: "Gateways", path: ~p"/#{@subject.account}/gateways"},
%{
label: "gcp-primary",
path: ~p"/#{@subject.account}/gateways/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"
label: "#{@gateway.name_suffix}",
path: ~p"/#{@subject.account}/gateways/#{@gateway.id}"
},
%{
label: "Edit",
path: ~p"/#{@subject.account}/gateways/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit"
path: ~p"/#{@subject.account}/gateways/#{@gateway.id}/edit"
}
]} />
</:breadcrumbs>
<:title>
Editing Gateway <code>gcp-primary</code>
Editing Gateway <code><%= @gateway.name_suffix %></code>
</:title>
</.section_header>
@@ -38,6 +45,7 @@ defmodule Web.GatewaysLive.Edit do
id="gateway-name"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
required=""
value={@gateway.name_suffix}
/>
</div>
</div>

View File

@@ -1,6 +1,30 @@
defmodule Web.GatewaysLive.Index do
use Web, :live_view
alias Domain.Gateways
alias Domain.Resources
def mount(_params, _session, socket) do
subject = socket.assigns.subject
{:ok, gateways} = Gateways.list_gateways(subject, preload: :group)
{_, resources} =
Enum.map_reduce(gateways, %{}, fn g, acc ->
{:ok, count} = Resources.count_resources_for_gateway(g, subject)
{count, Map.put(acc, g.id, count)}
end)
grouped_gateways = Enum.group_by(gateways, fn g -> g.group end)
socket =
assign(socket,
grouped_gateways: grouped_gateways,
resources: resources
)
{:ok, socket}
end
def render(assigns) do
~H"""
<.section_header>
@@ -15,357 +39,95 @@ defmodule Web.GatewaysLive.Index do
</:title>
<:actions>
<.add_button navigate={~p"/#{@subject.account}/gateways/new"}>
Add Gateway
Add Instance Group
</.add_button>
</:actions>
</.section_header>
<!-- Gateways Table -->
<div class="bg-white dark:bg-gray-800 overflow-hidden">
<div class="flex flex-col md:flex-row items-center justify-between space-y-3 md:space-y-0 md:space-x-4 p-4">
<div class="w-full md:w-1/2">
<form class="flex items-center">
<label for="simple-search" class="sr-only">Search</label>
<div class="relative w-full">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<.icon name="hero-magnifying-glass" class="w-5 h-5 text-gray-500 dark:text-gray-400" />
</div>
<input
type="text"
id="simple-search"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full pl-10 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Search"
required=""
/>
</div>
</form>
</div>
<.button_group>
<:first>
All
</:first>
<:middle>
Online
</:middle>
<:last>
Deleted
</:last>
</.button_group>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th scope="col" class="px-4 py-3">
<div class="flex items-center">
Name
<a href="#">
<.icon name="hero-chevron-up-down-solid" class="w-4 h-4 ml-1" />
</a>
</div>
</th>
<th scope="col" class="px-4 py-3">
<div class="flex items-center">
Remote IP
<a href="#">
<.icon name="hero-chevron-up-down-solid" class="w-4 h-4 ml-1" />
</a>
</div>
</th>
<th scope="col" class="px-4 py-3">
<div class="flex items-center">
Linked resources
<a href="#">
<.icon name="hero-chevron-up-down-solid" class="w-4 h-4 ml-1" />
</a>
</div>
</th>
<th scope="col" class="px-4 py-3">
<div class="flex items-center">
Status
<a href="#">
<.icon name="hero-chevron-up-down-solid" class="w-4 h-4 ml-1" />
</a>
</div>
</th>
<th scope="col" class="px-4 py-3">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
<tr class="border-b dark:border-gray-700">
<th
scope="row"
class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white"
>
<.link
navigate={~p"/#{@subject.account}/gateways/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
hungry-hippo
</.link>
</th>
<td class="px-4 py-3">
<code class="block text-xs">11.231.231.5</code>
<code class="block text-xs">2001:0db8:85a3:0000:0000:8a2e:0370:7334</code>
</td>
<td class="px-4 py-3">
<.link
navigate={~p"/#{@subject.account}/gateways/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
GitLab, Jira, Confluence, and 2 more
</.link>
</td>
<td class="px-4 py-3">
<span class="bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-green-900 dark:text-green-300">
Online
</span>
</td>
<td class="px-4 py-3 flex items-center justify-end">
<button
id="gateway-1-dropdown-button"
data-dropdown-toggle="gateway-1-dropdown"
class="inline-flex items-center p-0.5 text-sm font-medium text-center text-gray-500 hover:text-gray-800 rounded-lg focus:outline-none dark:text-gray-400 dark:hover:text-gray-100"
type="button"
>
<.icon name="hero-ellipsis-horizontal" class="w-5 h-5" />
</button>
<div
id="gateway-1-dropdown"
class="hidden z-10 w-44 bg-white rounded divide-y divide-gray-100 shadow dark:bg-gray-700 dark:divide-gray-600"
>
<ul
class="py-1 text-sm text-gray-700 dark:text-gray-200"
aria-labelledby="gateway-1-dropdown-button"
>
<li>
<a
href="#"
class="block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Show
</a>
</li>
<li>
<a
href="#"
class="block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Delete
</a>
</li>
</ul>
</div>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th
scope="row"
class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white"
>
<.link
navigate={~p"/#{@subject.account}/gateways/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
gcp-primary
</.link>
</th>
<td class="px-4 py-3">
<code class="block text-xs">1.1.1.2</code>
<code class="block text-xs">2156:0db8:85a3:0000:0000:8a2e:0370:0001</code>
</td>
<td class="px-4 py-3">
<.link
navigate={~p"/#{@subject.account}/gateways/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
10.56.7.0/24
</.link>
</td>
<td class="px-4 py-3">
<span class="bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-green-900 dark:text-green-300">
Online
</span>
</td>
<td class="px-4 py-3 flex items-center justify-end">
<button
id="gateway-2-dropdown-button"
data-dropdown-toggle="gateway-2-dropdown"
class="inline-flex items-center p-0.5 text-sm font-medium text-center text-gray-500 hover:text-gray-800 rounded-lg focus:outline-none dark:text-gray-400 dark:hover:text-gray-100"
type="button"
>
<.icon name="hero-ellipsis-horizontal" class="w-5 h-5" />
</button>
<div
id="gateway-2-dropdown"
class="hidden z-10 w-44 bg-white rounded divide-y divide-gray-100 shadow dark:bg-gray-700 dark:divide-gray-600"
>
<ul
class="py-1 text-sm text-gray-700 dark:text-gray-200"
aria-labelledby="gateway-2-dropdown-button"
>
<li>
<a
href="#"
class="block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Show
</a>
</li>
<li>
<a
href="#"
class="block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Delete
</a>
</li>
</ul>
</div>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th
scope="row"
class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white"
>
<.link
navigate={~p"/#{@subject.account}/gateways/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
gcp-secondary
</.link>
</th>
<td class="px-4 py-3">
<code class="block text-xs">1.1.1.2</code>
<code class="block text-xs">2156:0db8:85a3:0000:0000:8a2e:0370:0002</code>
</td>
<td class="px-4 py-3">
<.link
navigate={~p"/#{@subject.account}/gateways/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
10.56.7.0/24
</.link>
</td>
<td class="px-4 py-3">
<span class="bg-yellow-100 text-yellow-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-yellow-900 dark:text-yellow-300">
Last seen 2 hours ago
</span>
</td>
<td class="px-4 py-3 flex items-center justify-end">
<button
id="gateway-3-dropdown-button"
data-dropdown-toggle="gateway-3-dropdown"
class="inline-flex items-center p-0.5 text-sm font-medium text-center text-gray-500 hover:text-gray-800 rounded-lg focus:outline-none dark:text-gray-400 dark:hover:text-gray-100"
type="button"
>
<.icon name="hero-ellipsis-horizontal" class="w-5 h-5" />
</button>
<div
id="gateway-3-dropdown"
class="hidden z-10 w-44 bg-white rounded divide-y divide-gray-100 shadow dark:bg-gray-700 dark:divide-gray-600"
>
<ul
class="py-1 text-sm text-gray-700 dark:text-gray-200"
aria-labelledby="gateway-3-dropdown-button"
>
<li>
<a
href="#"
class="block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Show
</a>
</li>
<li>
<a
href="#"
class="block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Delete
</a>
</li>
</ul>
</div>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th
scope="row"
class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white"
>
<.link
navigate={~p"/#{@subject.account}/gateways/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
wavering-walrus
</.link>
</th>
<td class="px-4 py-3">
<code class="block text-xs">
12.47.11.102
</code>
<code class="block text-xs">
2006:0db8:85a3:0000:0000:8a2e:0370:7334
</code>
</td>
<td class="px-4 py-4">
<.link
navigate={~p"/#{@subject.account}/gateways/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
GitLab, Jira, Confluence, and 2 more
</.link>
</td>
<td class="px-4 py-3">
<span class="bg-gray-100 text-gray-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-gray-700 dark:text-gray-300">
Deleted 6 months ago
</span>
</td>
<td class="px-4 py-3 flex items-center justify-end">
<button
id="gateway-4-dropdown-button"
data-dropdown-toggle="gateway-4-dropdown"
class="inline-flex items-center p-0.5 text-sm font-medium text-center text-gray-500 hover:text-gray-800 rounded-lg focus:outline-none dark:text-gray-400 dark:hover:text-gray-100"
type="button"
>
<.icon name="hero-ellipsis-horizontal" class="w-5 h-5" />
</button>
<div
id="gateway-4-dropdown"
class="hidden z-10 w-44 bg-white rounded divide-y divide-gray-100 shadow dark:bg-gray-700 dark:divide-gray-600"
>
<ul
class="py-1 text-sm text-gray-700 dark:text-gray-200"
aria-labelledby="gateway-4-dropdown-button"
>
<li>
<a
href="#"
class="block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Show
</a>
</li>
<li>
<a
href="#"
class="block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Delete
</a>
</li>
</ul>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<.resource_filter />
<.table_with_groups id="grouped-gateways" rows={@grouped_gateways} row_id={&"gateway-#{&1.id}"}>
<:col label="INSTANCE GROUP"></:col>
<:col :let={gateway} label="INSTANCE">
<.link
navigate={~p"/#{@subject.account}/gateways/#{gateway.id}"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
<%= gateway.name_suffix %>
</.link>
</:col>
<:col :let={gateway} label="REMOTE IP">
<code class="block text-xs">
<%= gateway.ipv4 %>
</code>
<code class="block text-xs">
<%= gateway.ipv6 %>
</code>
</:col>
<:col :let={gateway} label="RESOURCES">
<.badge>
<%= @resources[gateway.id] || "0" %>
</.badge>
</:col>
<:col :let={_gateway} label="STATUS">
<.badge type="success">
TODO: Online
</.badge>
</:col>
<:action :let={gateway}>
<.link
navigate={~p"/#{@subject.account}/gateways/#{gateway.id}"}
class="block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Show
</.link>
</:action>
<:action :let={_gateway}>
<a
href="#"
class="block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Delete
</a>
</:action>
</.table_with_groups>
<.paginator page={3} total_pages={100} collection_base_path={~p"/#{@subject.account}/gateways"} />
</div>
"""
end
defp resource_filter(assigns) do
~H"""
<div class="flex flex-col md:flex-row items-center justify-between space-y-3 md:space-y-0 md:space-x-4 p-4">
<div class="w-full md:w-1/2">
<form class="flex items-center">
<label for="simple-search" class="sr-only">Search</label>
<div class="relative w-full">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<.icon name="hero-magnifying-glass" class="w-5 h-5 text-gray-500 dark:text-gray-400" />
</div>
<input
type="text"
id="simple-search"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full pl-10 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Search"
required=""
/>
</div>
</form>
</div>
<.button_group>
<:first>
All
</:first>
<:middle>
Online
</:middle>
<:last>
Deleted
</:last>
</.button_group>
</div>
"""
end
end

View File

@@ -1,6 +1,15 @@
defmodule Web.GatewaysLive.Show do
use Web, :live_view
alias Domain.Gateways
alias Domain.Resources
def mount(%{"id" => id} = _params, _session, socket) do
{:ok, gateway} = Gateways.fetch_gateway_by_id(id, socket.assigns.subject, preload: :group)
{:ok, resources} = Resources.list_resources_for_gateway(gateway, socket.assigns.subject)
{:ok, assign(socket, gateway: gateway, resources: resources)}
end
def render(assigns) do
~H"""
<.section_header>
@@ -9,21 +18,14 @@ defmodule Web.GatewaysLive.Show do
%{label: "Home", path: ~p"/#{@subject.account}/dashboard"},
%{label: "Gateways", path: ~p"/#{@subject.account}/gateways"},
%{
label: "gcp-primary",
path: ~p"/#{@subject.account}/gateways/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"
label: @gateway.name_suffix,
path: ~p"/#{@subject.account}/gateways/#{@gateway.id}"
}
]} />
</:breadcrumbs>
<:title>
Viewing Gateway <code>gcp-primary</code>
Gateway: <code><%= @gateway.name_suffix %></code>
</:title>
<:actions>
<.edit_button navigate={
~p"/#{@subject.account}/gateways/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit"
}>
Edit Gateway
</.edit_button>
</:actions>
</.section_header>
<!-- Gateway details -->
<div class="bg-white dark:bg-gray-800 overflow-hidden">
@@ -34,10 +36,23 @@ defmodule Web.GatewaysLive.Show do
scope="row"
class="text-right px-6 py-4 font-medium text-gray-900 whitespace-nowrap bg-gray-50 dark:text-white dark:bg-gray-800"
>
Name
Instance Group Name
</th>
<td class="px-6 py-4">
gcp-primary
<.badge type="info">
<%= @gateway.group.name_prefix %>
</.badge>
</td>
</tr>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th
scope="row"
class="text-right px-6 py-4 font-medium text-gray-900 whitespace-nowrap bg-gray-50 dark:text-white dark:bg-gray-800"
>
Instance Name
</th>
<td class="px-6 py-4">
<%= @gateway.name_suffix %>
</td>
</tr>
<tr class="border-b border-gray-200 dark:border-gray-700">
@@ -48,7 +63,7 @@ defmodule Web.GatewaysLive.Show do
Connectivity
</th>
<td class="px-6 py-4">
Peer to Peer
TODO: Peer to Peer
</td>
</tr>
<tr class="border-b border-gray-200 dark:border-gray-700">
@@ -59,7 +74,9 @@ defmodule Web.GatewaysLive.Show do
Status
</th>
<td class="px-6 py-4">
Online
<.badge type="success">
TODO: Online
</.badge>
</td>
</tr>
<tr class="border-b border-gray-200 dark:border-gray-700">
@@ -70,7 +87,9 @@ defmodule Web.GatewaysLive.Show do
Location
</th>
<td class="px-6 py-4">
San Jose, CA
<code>
<%= @gateway.last_seen_remote_ip %>
</code>
</td>
</tr>
<tr class="border-b border-gray-200 dark:border-gray-700">
@@ -81,7 +100,9 @@ defmodule Web.GatewaysLive.Show do
Last seen
</th>
<td class="px-6 py-4">
1 hour ago in San Francisco, CA
<.relative_datetime relative={@gateway.last_seen_at} />
<br />
<%= @gateway.last_seen_at %>
</td>
</tr>
<tr class="border-b border-gray-200 dark:border-gray-700">
@@ -92,7 +113,7 @@ defmodule Web.GatewaysLive.Show do
Remote IPv4
</th>
<td class="px-6 py-4">
<code>69.100.123.11</code>
<code><%= @gateway.ipv4 %></code>
</td>
</tr>
<tr class="border-b border-gray-200 dark:border-gray-700">
@@ -103,7 +124,7 @@ defmodule Web.GatewaysLive.Show do
Remote IPv6
</th>
<td class="px-6 py-4">
<code>2001:0db8:85a3:0000:0000:8a2e:0370:7334</code>
<code><%= @gateway.ipv6 %></code>
</td>
</tr>
<tr class="border-b border-gray-200 dark:border-gray-700">
@@ -114,7 +135,7 @@ defmodule Web.GatewaysLive.Show do
Transfer
</th>
<td class="px-6 py-4">
4.43 GB up, 1.23 GB down
TODO: 4.43 GB up, 1.23 GB down
</td>
</tr>
<tr class="border-b border-gray-200 dark:border-gray-700">
@@ -125,18 +146,10 @@ defmodule Web.GatewaysLive.Show do
Gateway version
</th>
<td class="px-6 py-4">
v1.01 for Linux/x86_64
</td>
</tr>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th
scope="row"
class="text-right px-6 py-4 font-medium text-gray-900 whitespace-nowrap bg-gray-50 dark:text-white dark:bg-gray-800"
>
OS version
</th>
<td class="px-6 py-4">
Linux 5.10.25-1-MANJARO x86_64
<%= "Gateway Version: #{@gateway.last_seen_version}" %>
<br />
<%= "User Agent: #{@gateway.last_seen_user_agent}" %>
</td>
</tr>
<tr class="border-b border-gray-200 dark:border-gray-700">
@@ -147,7 +160,7 @@ defmodule Web.GatewaysLive.Show do
Deployment method
</th>
<td class="px-6 py-4">
Docker
TODO: Docker
</td>
</tr>
</tbody>
@@ -162,52 +175,19 @@ defmodule Web.GatewaysLive.Show do
</div>
</div>
<div class="relative overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-900 uppercase dark:text-gray-400">
<tr>
<th scope="col" class="px-6 py-3">
Name
</th>
<th scope="col" class="px-6 py-3">
Address
</th>
</tr>
</thead>
<tbody>
<tr class="bg-white dark:bg-gray-800">
<th
scope="row"
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"
>
<.link
navigate={~p"/#{@subject.account}/resources/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
Engineering GitLab
</.link>
</th>
<td class="px-6 py-4">
gitlab.company.com
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th
scope="row"
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"
>
<.link
navigate={~p"/#{@subject.account}/resources/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
SJC VPC-1
</.link>
</th>
<td class="px-6 py-4">
172.16.45.0/24
</td>
</tr>
</tbody>
</table>
<.table id="resources" rows={@resources}>
<:col :let={resource} label="NAME">
<.link
navigate={~p"/#{@subject.account}/resources/#{resource.id}"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
<%= resource.name %>
</.link>
</:col>
<:col :let={resource} label="ADDRESS">
<%= resource.address %>
</:col>
</.table>
</div>
<.section_header>

View File

@@ -5,6 +5,7 @@ defmodule Web.SettingsLive.IdentityProviders.New.Components do
use Phoenix.Component
use Web, :verified_routes
import Web.CoreComponents
import Web.FormComponents
@doc """
Conditionally renders form fields corresponding to a given provisioning strategy type.