summaryrefslogtreecommitdiff
path: root/src/flagd-ui/lib/flagd_ui_web
diff options
context:
space:
mode:
authorSaumit <justsaumit@protonmail.com>2025-09-27 02:14:26 +0530
committerSaumit <justsaumit@protonmail.com>2025-09-27 02:14:26 +0530
commit82e03978b89938219958032efb1448cc76baa181 (patch)
tree626f3e54d52ecd49be0ed3bee30abacc0453d081 /src/flagd-ui/lib/flagd_ui_web
Initial snapshot - OpenTelemetry demo 2.1.3 -f
Diffstat (limited to 'src/flagd-ui/lib/flagd_ui_web')
-rw-r--r--src/flagd-ui/lib/flagd_ui_web/components/core_components.ex464
-rw-r--r--src/flagd-ui/lib/flagd_ui_web/components/layouts.ex111
-rw-r--r--src/flagd-ui/lib/flagd_ui_web/components/layouts/root.html.heex34
-rw-r--r--src/flagd-ui/lib/flagd_ui_web/components/navbar.ex41
-rw-r--r--src/flagd-ui/lib/flagd_ui_web/controllers/error_html.ex27
-rw-r--r--src/flagd-ui/lib/flagd_ui_web/controllers/error_json.ex24
-rw-r--r--src/flagd-ui/lib/flagd_ui_web/controllers/feature_controller.ex12
-rw-r--r--src/flagd-ui/lib/flagd_ui_web/controllers/page_html.ex13
-rw-r--r--src/flagd-ui/lib/flagd_ui_web/endpoint.ex50
-rw-r--r--src/flagd-ui/lib/flagd_ui_web/gettext.ex28
-rw-r--r--src/flagd-ui/lib/flagd_ui_web/live/advanced_editor.ex92
-rw-r--r--src/flagd-ui/lib/flagd_ui_web/live/dashboard.ex66
-rw-r--r--src/flagd-ui/lib/flagd_ui_web/router.ex50
-rw-r--r--src/flagd-ui/lib/flagd_ui_web/telemetry.ex73
14 files changed, 1085 insertions, 0 deletions
diff --git a/src/flagd-ui/lib/flagd_ui_web/components/core_components.ex b/src/flagd-ui/lib/flagd_ui_web/components/core_components.ex
new file mode 100644
index 0000000..5792123
--- /dev/null
+++ b/src/flagd-ui/lib/flagd_ui_web/components/core_components.ex
@@ -0,0 +1,464 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+defmodule FlagdUiWeb.CoreComponents do
+ @moduledoc """
+ Provides core UI components.
+
+ At first glance, this module may seem daunting, but its goal is to provide
+ core building blocks for your application, such as tables, forms, and
+ inputs. The components consist mostly of markup and are well-documented
+ with doc strings and declarative assigns. You may customize and style
+ them in any way you want, based on your application growth and needs.
+
+ The foundation for styling is Tailwind CSS, a utility-first CSS framework,
+ augmented with daisyUI, a Tailwind CSS plugin that provides UI components
+ and themes. Here are useful references:
+
+ * [daisyUI](https://daisyui.com/docs/intro/) - a good place to get
+ started and see the available components.
+
+ * [Tailwind CSS](https://tailwindcss.com) - the foundational framework
+ we build on. You will use it for layout, sizing, flexbox, grid, and
+ spacing.
+
+ * [Heroicons](https://heroicons.com) - see `icon/1` for usage.
+
+ * [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) -
+ the component system used by Phoenix. Some components, such as `<.link>`
+ and `<.form>`, are defined there.
+
+ """
+ use Phoenix.Component
+ use Gettext, backend: FlagdUiWeb.Gettext
+
+ alias Phoenix.LiveView.JS
+
+ @doc """
+ Renders flash notices.
+
+ ## Examples
+
+ <.flash kind={:info} flash={@flash} />
+ <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
+ """
+ attr :id, :string, doc: "the optional id of flash container"
+ attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
+ attr :title, :string, default: nil
+ attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
+ attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
+
+ slot :inner_block, doc: "the optional inner block that renders the flash message"
+
+ def flash(assigns) do
+ assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
+
+ ~H"""
+ <div
+ :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
+ id={@id}
+ phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
+ role="alert"
+ class="toast toast-top toast-end z-50"
+ {@rest}
+ >
+ <div class={[
+ "alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
+ @kind == :info && "alert-info",
+ @kind == :error && "alert-error"
+ ]}>
+ <.icon :if={@kind == :info} name="hero-information-circle-mini" class="size-5 shrink-0" />
+ <.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="size-5 shrink-0" />
+ <div>
+ <p :if={@title} class="font-semibold">{@title}</p>
+ <p>{msg}</p>
+ </div>
+ <div class="flex-1" />
+ <button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
+ <.icon name="hero-x-mark-solid" class="size-5 opacity-40 group-hover:opacity-70" />
+ </button>
+ </div>
+ </div>
+ """
+ end
+
+ @doc """
+ Renders a button with navigation support.
+
+ ## Examples
+
+ <.button>Send!</.button>
+ <.button phx-click="go" variant="primary">Send!</.button>
+ <.button navigate={~p"/"}>Home</.button>
+ """
+ attr :rest, :global, include: ~w(href navigate patch)
+ attr :variant, :string, values: ~w(primary)
+ slot :inner_block, required: true
+
+ def button(%{rest: rest} = assigns) do
+ variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
+ assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
+
+ if rest[:href] || rest[:navigate] || rest[:patch] do
+ ~H"""
+ <.link class={["btn", @class]} {@rest}>
+ {render_slot(@inner_block)}
+ </.link>
+ """
+ else
+ ~H"""
+ <button class={["btn", @class]} {@rest}>
+ {render_slot(@inner_block)}
+ </button>
+ """
+ end
+ end
+
+ @doc """
+ Renders an input with label and error messages.
+
+ A `Phoenix.HTML.FormField` may be passed as argument,
+ which is used to retrieve the input name, id, and values.
+ Otherwise all attributes may be passed explicitly.
+
+ ## Types
+
+ This function accepts all HTML input types, considering that:
+
+ * You may also set `type="select"` to render a `<select>` tag
+
+ * `type="checkbox"` is used exclusively to render boolean values
+
+ * For live file uploads, see `Phoenix.Component.live_file_input/1`
+
+ See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
+ for more information. Unsupported types, such as hidden and radio,
+ are best written directly in your templates.
+
+ ## 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 month number password
+ range 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 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(accept autocomplete capture cols disabled form list max maxlength min minlength
+ multiple pattern placeholder readonly required rows size step)
+
+ def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
+ errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
+
+ assigns
+ |> assign(field: nil, id: assigns.id || field.id)
+ |> assign(:errors, Enum.map(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: "checkbox"} = assigns) do
+ assigns =
+ assign_new(assigns, :checked, fn ->
+ Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
+ end)
+
+ ~H"""
+ <fieldset class="fieldset mb-2">
+ <label>
+ <input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
+ <span class="fieldset-label">
+ <input
+ type="checkbox"
+ id={@id}
+ name={@name}
+ value="true"
+ checked={@checked}
+ class="checkbox checkbox-sm"
+ {@rest}
+ />{@label}
+ </span>
+ </label>
+ <.error :for={msg <- @errors}>{msg}</.error>
+ </fieldset>
+ """
+ end
+
+ def input(%{type: "select"} = assigns) do
+ ~H"""
+ <fieldset class="fieldset mb-2">
+ <label>
+ <span :if={@label} class="fieldset-label mb-1">{@label}</span>
+ <select
+ id={@id}
+ name={@name}
+ class={["w-full select", @errors != [] && "select-error"]}
+ multiple={@multiple}
+ {@rest}
+ >
+ <option :if={@prompt} value="">{@prompt}</option>
+ {Phoenix.HTML.Form.options_for_select(@options, @value)}
+ </select>
+ </label>
+ <.error :for={msg <- @errors}>{msg}</.error>
+ </fieldset>
+ """
+ end
+
+ def input(%{type: "textarea"} = assigns) do
+ ~H"""
+ <fieldset class="fieldset mb-2">
+ <label>
+ <span :if={@label} class="fieldset-label mb-1">{@label}</span>
+ <textarea
+ id={@id}
+ name={@name}
+ class={["w-full textarea", @errors != [] && "textarea-error"]}
+ {@rest}
+ >{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
+ </label>
+ <.error :for={msg <- @errors}>{msg}</.error>
+ </fieldset>
+ """
+ end
+
+ # All other inputs text, datetime-local, url, password, etc. are handled here...
+ def input(assigns) do
+ ~H"""
+ <fieldset class="fieldset mb-2">
+ <label>
+ <span :if={@label} class="fieldset-label mb-1">{@label}</span>
+ <input
+ type={@type}
+ name={@name}
+ id={@id}
+ value={Phoenix.HTML.Form.normalize_value(@type, @value)}
+ class={["w-full input", @errors != [] && "input-error"]}
+ {@rest}
+ />
+ </label>
+ <.error :for={msg <- @errors}>{msg}</.error>
+ </fieldset>
+ """
+ end
+
+ # Helper used by inputs to generate form errors
+ defp error(assigns) do
+ ~H"""
+ <p class="mt-1.5 flex gap-2 items-center text-sm text-error">
+ <.icon name="hero-exclamation-circle-mini" class="size-5" />
+ {render_slot(@inner_block)}
+ </p>
+ """
+ end
+
+ @doc """
+ Renders a header with title.
+ """
+ attr :class, :string, default: nil
+
+ slot :inner_block, required: true
+ slot :subtitle
+ slot :actions
+
+ def header(assigns) do
+ ~H"""
+ <header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4", @class]}>
+ <div>
+ <h1 class="text-lg font-semibold leading-8">
+ {render_slot(@inner_block)}
+ </h1>
+ <p :if={@subtitle != []} class="text-sm text-base-content/70">
+ {render_slot(@subtitle)}
+ </p>
+ </div>
+ <div class="flex-none">{render_slot(@actions)}</div>
+ </header>
+ """
+ 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"""
+ <table class="table table-zebra">
+ <thead>
+ <tr>
+ <th :for={col <- @col}>{col[:label]}</th>
+ <th :if={@action != []}>
+ <span class="sr-only">{gettext("Actions")}</span>
+ </th>
+ </tr>
+ </thead>
+ <tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
+ <tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
+ <td
+ :for={col <- @col}
+ phx-click={@row_click && @row_click.(row)}
+ class={@row_click && "hover:cursor-pointer"}
+ >
+ {render_slot(col, @row_item.(row))}
+ </td>
+ <td :if={@action != []} class="w-0 font-semibold">
+ <div class="flex gap-4">
+ <%= for action <- @action do %>
+ {render_slot(action, @row_item.(row))}
+ <% end %>
+ </div>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ """
+ end
+
+ @doc """
+ Renders a data list.
+
+ ## Examples
+
+ <.list>
+ <:item title="Title">{@post.title}</:item>
+ <:item title="Views">{@post.views}</:item>
+ </.list>
+ """
+ slot :item, required: true do
+ attr :title, :string, required: true
+ end
+
+ def list(assigns) do
+ ~H"""
+ <ul class="list">
+ <li :for={item <- @item} class="list-row">
+ <div>
+ <div class="font-bold">{item.title}</div>
+ <div>{render_slot(item)}</div>
+ </div>
+ </li>
+ </ul>
+ """
+ end
+
+ @doc """
+ Renders a [Heroicon](https://heroicons.com).
+
+ Heroicons come in three styles – outline, solid, and mini.
+ By default, the outline style is used, but solid and mini may
+ be applied by using the `-solid` and `-mini` suffix.
+
+ You can customize the size and colors of the icons by setting
+ width, height, and background color classes.
+
+ Icons are extracted from the `deps/heroicons` directory and bundled within
+ your compiled app.css by the plugin in `assets/vendor/heroicons.js`.
+
+ ## Examples
+
+ <.icon name="hero-x-mark-solid" />
+ <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
+ """
+ attr :name, :string, required: true
+ attr :class, :string, default: "size-4"
+
+ def icon(%{name: "hero-" <> _} = assigns) do
+ ~H"""
+ <span class={[@name, @class]} />
+ """
+ end
+
+ ## JS Commands
+
+ def show(js \\ %JS{}, selector) do
+ JS.show(js,
+ to: selector,
+ time: 300,
+ transition:
+ {"transition-all ease-out duration-300",
+ "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
+ "opacity-100 translate-y-0 sm:scale-100"}
+ )
+ end
+
+ def hide(js \\ %JS{}, selector) do
+ JS.hide(js,
+ to: selector,
+ time: 200,
+ transition:
+ {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
+ "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
+ )
+ end
+
+ @doc """
+ Translates an error message using gettext.
+ """
+ def translate_error({msg, opts}) do
+ # When using gettext, we typically pass the strings we want
+ # to translate as a static argument:
+ #
+ # # Translate the number of files with plural rules
+ # dngettext("errors", "1 file", "%{count} files", count)
+ #
+ # However the error messages in our forms and APIs are generated
+ # dynamically, so we need to translate them by calling Gettext
+ # with our gettext backend as first argument. Translations are
+ # available in the errors.po file (as we use the "errors" domain).
+ if count = opts[:count] do
+ Gettext.dngettext(FlagdUiWeb.Gettext, "errors", msg, msg, count, opts)
+ else
+ Gettext.dgettext(FlagdUiWeb.Gettext, "errors", msg, opts)
+ end
+ end
+
+ @doc """
+ Translates the errors for a field from a keyword list of errors.
+ """
+ def translate_errors(errors, field) when is_list(errors) do
+ for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
+ end
+end
diff --git a/src/flagd-ui/lib/flagd_ui_web/components/layouts.ex b/src/flagd-ui/lib/flagd_ui_web/components/layouts.ex
new file mode 100644
index 0000000..164da11
--- /dev/null
+++ b/src/flagd-ui/lib/flagd_ui_web/components/layouts.ex
@@ -0,0 +1,111 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+defmodule FlagdUiWeb.Layouts do
+ @moduledoc """
+ This module holds different layouts used by your application.
+
+ See the `layouts` directory for all templates available.
+ The "root" layout is a skeleton rendered as part of the
+ application router. The "app" layout is rendered as component
+ in regular views and live views.
+ """
+ use FlagdUiWeb, :html
+
+ embed_templates "layouts/*"
+
+ def app(assigns) do
+ ~H"""
+ <header class="navbar px-4 sm:px-6 lg:px-8">
+ <div class="flex-1">
+ <a href="/" class="flex-1 flex items-center gap-2">
+ <span class="text-lg font-semibold">Flagd UI</span>
+ </a>
+ </div>
+ <div class="flex-none">
+ <ul class="flex flex-column px-1 space-x-4 items-center">
+ <li>
+ <.theme_toggle />
+ </li>
+ </ul>
+ </div>
+ </header>
+
+ <main class="px-4 py-20 sm:px-6 lg:px-8">
+ <div class="mx-auto max-w-2xl space-y-4">
+ {render_slot(@inner_block)}
+ </div>
+ </main>
+
+ <.flash_group flash={@flash} />
+ """
+ end
+
+ @doc """
+ Shows the flash group with standard titles and content.
+
+ ## Examples
+
+ <.flash_group flash={@flash} />
+ """
+ attr :flash, :map, required: true, doc: "the map of flash messages"
+ attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
+
+ def flash_group(assigns) do
+ ~H"""
+ <div id={@id} aria-live="polite">
+ <.flash kind={:info} flash={@flash} />
+ <.flash kind={:error} flash={@flash} />
+
+ <.flash
+ id="client-error"
+ kind={:error}
+ title={gettext("We can't find the internet")}
+ phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
+ phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
+ hidden
+ >
+ {gettext("Attempting to reconnect")}
+ <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 motion-safe:animate-spin" />
+ </.flash>
+
+ <.flash
+ id="server-error"
+ kind={:error}
+ title={gettext("Something went wrong!")}
+ phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
+ phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
+ hidden
+ >
+ {gettext("Hang in there while we get back on track")}
+ <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 motion-safe:animate-spin" />
+ </.flash>
+ </div>
+ """
+ end
+
+ @doc """
+ Provides dark vs light theme toggle based on themes defined in app.css.
+
+ See <head> in root.html.heex which applies the theme before page load.
+ """
+ def theme_toggle(assigns) do
+ ~H"""
+ <div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
+ <div class="absolute w-[33%] h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme=light]_&]:left-[33%] [[data-theme=dark]_&]:left-[66%] transition-[left]" />
+
+ <button phx-click={JS.dispatch("phx:set-theme", detail: %{theme: "system"})} class="flex p-2">
+ <.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" />
+ </button>
+
+ <button phx-click={JS.dispatch("phx:set-theme", detail: %{theme: "light"})} class="flex p-2">
+ <.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" />
+ </button>
+
+ <button phx-click={JS.dispatch("phx:set-theme", detail: %{theme: "dark"})} class="flex p-2">
+ <.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" />
+ </button>
+ </div>
+ """
+ end
+end
diff --git a/src/flagd-ui/lib/flagd_ui_web/components/layouts/root.html.heex b/src/flagd-ui/lib/flagd_ui_web/components/layouts/root.html.heex
new file mode 100644
index 0000000..010a552
--- /dev/null
+++ b/src/flagd-ui/lib/flagd_ui_web/components/layouts/root.html.heex
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <meta name="csrf-token" content={get_csrf_token()} />
+ <meta name="root-path" content={FlagdUiWeb.Endpoint.get_root_path()} />
+ <.live_title default="Flagd-ui">
+ {assigns[:page_title]}
+ </.live_title>
+ <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
+ <script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
+ </script>
+ <script>
+ (() => {
+ const setTheme = (theme) => {
+ if (theme === "system") {
+ localStorage.removeItem("phx:theme");
+ document.documentElement.removeAttribute("data-theme");
+ } else {
+ localStorage.setItem("phx:theme", theme);
+ document.documentElement.setAttribute("data-theme", theme);
+ }
+ };
+ setTheme(localStorage.getItem("phx:theme") || "system");
+ window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
+ window.addEventListener("phx:set-theme", ({ detail: { theme } }) => setTheme(theme));
+ })();
+ </script>
+ </head>
+ <body>
+ {@inner_content}
+ </body>
+</html>
diff --git a/src/flagd-ui/lib/flagd_ui_web/components/navbar.ex b/src/flagd-ui/lib/flagd_ui_web/components/navbar.ex
new file mode 100644
index 0000000..b224f51
--- /dev/null
+++ b/src/flagd-ui/lib/flagd_ui_web/components/navbar.ex
@@ -0,0 +1,41 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+defmodule FlagdUiWeb.Components.Navbar do
+ use Phoenix.Component
+ use FlagdUiWeb, :live_view
+
+ attr :mode, :string, default: "basic", doc: "the view currently displaying"
+
+ def navbar(assigns) do
+ ~H"""
+ <nav class="bg-gray-800 p-4 sm:p-6">
+ <div class="container mx-auto flex items-center justify-between">
+ <a href="/feature" class="text-xl font-bold text-white">
+ Flagd Configurator
+ </a>
+ <ul class="flex space-x-2 sm:space-x-4">
+ <li>
+ <a href="/feature" class={classes("basic", @mode)}>
+ Basic
+ </a>
+ </li>
+ <li>
+ <a href="/feature/advanced" class={classes("advanced", @mode)}>
+ Advanced
+ </a>
+ </li>
+ </ul>
+ </div>
+ </nav>
+ """
+ end
+
+ defp classes(route, route),
+ do:
+ "rounded-md px-3 py-2 text-sm font-medium bg-blue-700 text-white underline underline-offset-4 transition-all duration-200"
+
+ defp classes(_, _),
+ do:
+ "rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white transition-all duration-200"
+end
diff --git a/src/flagd-ui/lib/flagd_ui_web/controllers/error_html.ex b/src/flagd-ui/lib/flagd_ui_web/controllers/error_html.ex
new file mode 100644
index 0000000..3440c0d
--- /dev/null
+++ b/src/flagd-ui/lib/flagd_ui_web/controllers/error_html.ex
@@ -0,0 +1,27 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+defmodule FlagdUiWeb.ErrorHTML do
+ @moduledoc """
+ This module is invoked by your endpoint in case of errors on HTML requests.
+
+ See config/config.exs.
+ """
+ use FlagdUiWeb, :html
+
+ # If you want to customize your error pages,
+ # uncomment the embed_templates/1 call below
+ # and add pages to the error directory:
+ #
+ # * lib/flagd_ui_web/controllers/error_html/404.html.heex
+ # * lib/flagd_ui_web/controllers/error_html/500.html.heex
+ #
+ # embed_templates "error_html/*"
+
+ # The default is to render a plain text page based on
+ # the template name. For example, "404.html" becomes
+ # "Not Found".
+ def render(template, _assigns) do
+ Phoenix.Controller.status_message_from_template(template)
+ end
+end
diff --git a/src/flagd-ui/lib/flagd_ui_web/controllers/error_json.ex b/src/flagd-ui/lib/flagd_ui_web/controllers/error_json.ex
new file mode 100644
index 0000000..a726099
--- /dev/null
+++ b/src/flagd-ui/lib/flagd_ui_web/controllers/error_json.ex
@@ -0,0 +1,24 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+defmodule FlagdUiWeb.ErrorJSON do
+ @moduledoc """
+ This module is invoked by your endpoint in case of errors on JSON requests.
+
+ See config/config.exs.
+ """
+
+ # If you want to customize a particular status code,
+ # you may add your own clauses, such as:
+ #
+ # def render("500.json", _assigns) do
+ # %{errors: %{detail: "Internal Server Error"}}
+ # end
+
+ # By default, Phoenix returns the status message from
+ # the template name. For example, "404.json" becomes
+ # "Not Found".
+ def render(template, _assigns) do
+ %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
+ end
+end
diff --git a/src/flagd-ui/lib/flagd_ui_web/controllers/feature_controller.ex b/src/flagd-ui/lib/flagd_ui_web/controllers/feature_controller.ex
new file mode 100644
index 0000000..551f225
--- /dev/null
+++ b/src/flagd-ui/lib/flagd_ui_web/controllers/feature_controller.ex
@@ -0,0 +1,12 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+defmodule FlagdUiWeb.FeatureController do
+ use FlagdUiWeb, :controller
+
+ def read(conn, _params) do
+ %{"flags" => flags} = GenServer.call(Storage, :read)
+
+ json(conn, %{"flags" => flags})
+ end
+end
diff --git a/src/flagd-ui/lib/flagd_ui_web/controllers/page_html.ex b/src/flagd-ui/lib/flagd_ui_web/controllers/page_html.ex
new file mode 100644
index 0000000..fc14663
--- /dev/null
+++ b/src/flagd-ui/lib/flagd_ui_web/controllers/page_html.ex
@@ -0,0 +1,13 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+defmodule FlagdUiWeb.PageHTML do
+ @moduledoc """
+ This module contains pages rendered by PageController.
+
+ See the `page_html` directory for all templates available.
+ """
+ use FlagdUiWeb, :html
+
+ embed_templates "page_html/*"
+end
diff --git a/src/flagd-ui/lib/flagd_ui_web/endpoint.ex b/src/flagd-ui/lib/flagd_ui_web/endpoint.ex
new file mode 100644
index 0000000..5f4d633
--- /dev/null
+++ b/src/flagd-ui/lib/flagd_ui_web/endpoint.ex
@@ -0,0 +1,50 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+defmodule FlagdUiWeb.Endpoint do
+ use Phoenix.Endpoint, otp_app: :flagd_ui
+
+ # The session will be stored in the cookie and signed,
+ # this means its contents can be read but not tampered with.
+ # Set :encryption_salt if you would also like to encrypt it.
+ @session_options [
+ store: :cookie,
+ key: "_flagd_ui_key",
+ signing_salt: "ZIW3lwrD",
+ same_site: "Lax"
+ ]
+
+ socket "/live", Phoenix.LiveView.Socket,
+ websocket: [connect_info: [session: @session_options]],
+ longpoll: [connect_info: [session: @session_options]]
+
+ # Serve at "/" the static files from "priv/static" directory.
+ #
+ # When code reloading is disabled (e.g., in production),
+ # the `gzip` option is enabled to serve compressed
+ # static files generated by running `phx.digest`.
+ plug Plug.Static,
+ at: "/",
+ from: :flagd_ui,
+ gzip: not code_reloading?,
+ only: FlagdUiWeb.static_paths()
+
+ plug Phoenix.LiveDashboard.RequestLogger,
+ param_key: "request_logger",
+ cookie_key: "request_logger"
+
+ plug Plug.RequestId
+ plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
+
+ plug Plug.Parsers,
+ parsers: [:urlencoded, :multipart, :json],
+ pass: ["*/*"],
+ json_decoder: Phoenix.json_library()
+
+ plug Plug.MethodOverride
+ plug Plug.Head
+ plug Plug.Session, @session_options
+ plug FlagdUiWeb.Router
+
+ def get_root_path, do: config(:url) |> Enum.find(fn {k, _} -> k == :path end) |> elem(1)
+end
diff --git a/src/flagd-ui/lib/flagd_ui_web/gettext.ex b/src/flagd-ui/lib/flagd_ui_web/gettext.ex
new file mode 100644
index 0000000..ac82fe7
--- /dev/null
+++ b/src/flagd-ui/lib/flagd_ui_web/gettext.ex
@@ -0,0 +1,28 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+defmodule FlagdUiWeb.Gettext do
+ @moduledoc """
+ A module providing Internationalization with a gettext-based API.
+
+ By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations
+ that you can use in your application. To use this Gettext backend module,
+ call `use Gettext` and pass it as an option:
+
+ use Gettext, backend: FlagdUiWeb.Gettext
+
+ # Simple translation
+ gettext("Here is the string to translate")
+
+ # Plural translation
+ ngettext("Here is the string to translate",
+ "Here are the strings to translate",
+ 3)
+
+ # Domain-based translation
+ dgettext("errors", "Here is the error message to translate")
+
+ See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
+ """
+ use Gettext.Backend, otp_app: :flagd_ui
+end
diff --git a/src/flagd-ui/lib/flagd_ui_web/live/advanced_editor.ex b/src/flagd-ui/lib/flagd_ui_web/live/advanced_editor.ex
new file mode 100644
index 0000000..fbecaa8
--- /dev/null
+++ b/src/flagd-ui/lib/flagd_ui_web/live/advanced_editor.ex
@@ -0,0 +1,92 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+defmodule FlagdUiWeb.AdvancedEditor do
+ use FlagdUiWeb, :live_view
+
+ alias FlagdUiWeb.CoreComponents
+ alias FlagdUiWeb.Components.Navbar
+
+ def mount(_, _, socket) do
+ state = GenServer.call(Storage, :read)
+ content = Jason.encode!(state, pretty: true)
+
+ {:ok,
+ socket
+ |> assign(content: content)
+ |> assign(unsaved_changes: false)}
+ end
+
+ def render(assigns) do
+ ~H"""
+ <div class="relative min-h-screen">
+ <Navbar.navbar mode="advanced" />
+
+ <CoreComponents.flash kind={:error} flash={@flash} />
+ <CoreComponents.flash kind={:info} flash={@flash} />
+
+ <div class="container mx-auto px-4 py-8">
+ <.form for={%{}}>
+ <textarea
+ name="content"
+ type="textarea"
+ class="mb-4 h-48 w-full bg-gray-700 p-3 text-sm text-gray-300 focus:border-blue-500 focus:outline-none sm:h-64 md:h-80 lg:h-96 xl:h-[32rem] 2xl:h-[48rem]"
+ cols={200}
+ phx-change="edit"
+ >
+ {Phoenix.HTML.Form.normalize_value("textarea", @content)}
+ </textarea>
+ <div>
+ <button
+ type="button"
+ class="rounded bg-blue-500 px-8 py-4 font-medium text-white transition-colors duration-200 hover:bg-blue-600"
+ phx-click="save"
+ >
+ Save
+ </button>
+ <p :if={@unsaved_changes} class="text-red-600">Unsaved changes</p>
+ </div>
+ </.form>
+ </div>
+ </div>
+ """
+ end
+
+ def handle_event("edit", payload, socket) do
+ %{"content" => content} = payload
+
+ {:noreply,
+ socket
+ |> assign(content: content)
+ |> assign(unsaved_changes: true)}
+ end
+
+ def handle_event(
+ "save",
+ _,
+ %{
+ assigns: %{
+ content: content
+ }
+ } = socket
+ ) do
+ new_socket =
+ case Jason.decode(content) do
+ {:ok, _} ->
+ trimmed_content = String.trim(content)
+
+ GenServer.cast(Storage, {:replace, trimmed_content})
+
+ socket
+ |> assign(unsaved_changes: false)
+ |> assign(content: trimmed_content)
+ |> clear_flash()
+ |> put_flash(:info, "Saved!")
+
+ {:error, _} ->
+ put_flash(socket, :error, "Invalid JSON")
+ end
+
+ {:noreply, new_socket}
+ end
+end
diff --git a/src/flagd-ui/lib/flagd_ui_web/live/dashboard.ex b/src/flagd-ui/lib/flagd_ui_web/live/dashboard.ex
new file mode 100644
index 0000000..cacd1d8
--- /dev/null
+++ b/src/flagd-ui/lib/flagd_ui_web/live/dashboard.ex
@@ -0,0 +1,66 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+defmodule FlagdUiWeb.Dashboard do
+ use FlagdUiWeb, :live_view
+
+ alias FlagdUiWeb.CoreComponents
+ alias FlagdUiWeb.Components.Navbar
+
+ def mount(_, _, socket) do
+ %{"flags" => flags} = GenServer.call(Storage, :read)
+ {:ok, socket |> assign(:flags, flags)}
+ end
+
+ def render(assigns) do
+ ~H"""
+ <div class="relative min-h-screen">
+ <Navbar.navbar />
+
+ <CoreComponents.flash kind={:error} flash={@flash} />
+ <CoreComponents.flash kind={:info} flash={@flash} />
+
+ <.form for={@flags}>
+ <div class="container mx-auto px-4 py-8">
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
+ <div
+ :for={{name, data} <- @flags}
+ class="mb-4 flex flex-auto flex-col justify-between rounded-md bg-gray-800 p-6 text-gray-300 shadow-md"
+ >
+ <div>
+ <p class="mb-4 text-lg font-semibold">{name}</p>
+ <p class="-4 text-sm">{data["description"]}</p>
+ </div>
+ <div>
+ <div class="flex items-center justify-between">
+ <CoreComponents.input
+ name={name}
+ type="select"
+ options={get_variants(data)}
+ value={data["defaultVariant"]}
+ phx-change="flag_changed"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </.form>
+ </div>
+ """
+ end
+
+ def handle_event("flag_changed", payload, socket) do
+ %{"_target" => [target]} = payload
+ variant = payload[target]
+
+ GenServer.cast(Storage, {:write, target, variant})
+
+ new_socket = put_flash(socket, :info, "Saved: #{target}")
+
+ {:noreply, new_socket}
+ end
+
+ defp get_variants(%{"variants" => variants}), do: Enum.map(variants, fn {key, _} -> key end)
+ defp get_variants(_), do: []
+end
diff --git a/src/flagd-ui/lib/flagd_ui_web/router.ex b/src/flagd-ui/lib/flagd_ui_web/router.ex
new file mode 100644
index 0000000..df7d8ba
--- /dev/null
+++ b/src/flagd-ui/lib/flagd_ui_web/router.ex
@@ -0,0 +1,50 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+defmodule FlagdUiWeb.Router do
+ use FlagdUiWeb, :router
+
+ pipeline :browser do
+ plug :accepts, ["html"]
+ plug :fetch_session
+ plug :fetch_live_flash
+ plug :put_root_layout, html: {FlagdUiWeb.Layouts, :root}
+ plug :protect_from_forgery
+ plug :put_secure_browser_headers
+ end
+
+ pipeline :api do
+ plug :accepts, ["json"]
+ end
+
+ scope "/", FlagdUiWeb do
+ pipe_through :browser
+
+ live "/", Dashboard
+ live "/advanced", AdvancedEditor
+ end
+
+ # Other scopes may use custom stacks.
+ scope "/api", FlagdUiWeb do
+ pipe_through :api
+
+ get "/read", FeatureController, :read
+ end
+
+ # Enable LiveDashboard and Swoosh mailbox preview in development
+ if Application.compile_env(:flagd_ui, :dev_routes) do
+ # If you want to use the LiveDashboard in production, you should put
+ # it behind authentication and allow only admins to access it.
+ # If your application does not have an admins-only section yet,
+ # you can use Plug.BasicAuth to set up some basic authentication
+ # as long as you are also using SSL (which you should anyway).
+ import Phoenix.LiveDashboard.Router
+
+ scope "/dev" do
+ pipe_through :browser
+
+ live_dashboard "/dashboard", metrics: FlagdUiWeb.Telemetry
+ forward "/mailbox", Plug.Swoosh.MailboxPreview
+ end
+ end
+end
diff --git a/src/flagd-ui/lib/flagd_ui_web/telemetry.ex b/src/flagd-ui/lib/flagd_ui_web/telemetry.ex
new file mode 100644
index 0000000..8afe61f
--- /dev/null
+++ b/src/flagd-ui/lib/flagd_ui_web/telemetry.ex
@@ -0,0 +1,73 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+defmodule FlagdUiWeb.Telemetry do
+ use Supervisor
+ import Telemetry.Metrics
+
+ def start_link(arg) do
+ Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
+ end
+
+ @impl true
+ def init(_arg) do
+ children = [
+ # Telemetry poller will execute the given period measurements
+ # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
+ {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
+ # Add reporters as children of your supervision tree.
+ # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
+ ]
+
+ Supervisor.init(children, strategy: :one_for_one)
+ end
+
+ def metrics do
+ [
+ # Phoenix Metrics
+ summary("phoenix.endpoint.start.system_time",
+ unit: {:native, :millisecond}
+ ),
+ summary("phoenix.endpoint.stop.duration",
+ unit: {:native, :millisecond}
+ ),
+ summary("phoenix.router_dispatch.start.system_time",
+ tags: [:route],
+ unit: {:native, :millisecond}
+ ),
+ summary("phoenix.router_dispatch.exception.duration",
+ tags: [:route],
+ unit: {:native, :millisecond}
+ ),
+ summary("phoenix.router_dispatch.stop.duration",
+ tags: [:route],
+ unit: {:native, :millisecond}
+ ),
+ summary("phoenix.socket_connected.duration",
+ unit: {:native, :millisecond}
+ ),
+ sum("phoenix.socket_drain.count"),
+ summary("phoenix.channel_joined.duration",
+ unit: {:native, :millisecond}
+ ),
+ summary("phoenix.channel_handled_in.duration",
+ tags: [:event],
+ unit: {:native, :millisecond}
+ ),
+
+ # VM Metrics
+ summary("vm.memory.total", unit: {:byte, :kilobyte}),
+ summary("vm.total_run_queue_lengths.total"),
+ summary("vm.total_run_queue_lengths.cpu"),
+ summary("vm.total_run_queue_lengths.io")
+ ]
+ end
+
+ defp periodic_measurements do
+ [
+ # A module, function and arguments to be invoked periodically.
+ # This function must call :telemetry.execute/3 and a metric must be added above.
+ # {FlagdUiWeb, :count_users, []}
+ ]
+ end
+end