diff options
| author | Saumit <justsaumit@protonmail.com> | 2025-09-27 02:14:26 +0530 |
|---|---|---|
| committer | Saumit <justsaumit@protonmail.com> | 2025-09-27 02:14:26 +0530 |
| commit | 82e03978b89938219958032efb1448cc76baa181 (patch) | |
| tree | 626f3e54d52ecd49be0ed3bee30abacc0453d081 /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.ex | 464 | ||||
| -rw-r--r-- | src/flagd-ui/lib/flagd_ui_web/components/layouts.ex | 111 | ||||
| -rw-r--r-- | src/flagd-ui/lib/flagd_ui_web/components/layouts/root.html.heex | 34 | ||||
| -rw-r--r-- | src/flagd-ui/lib/flagd_ui_web/components/navbar.ex | 41 | ||||
| -rw-r--r-- | src/flagd-ui/lib/flagd_ui_web/controllers/error_html.ex | 27 | ||||
| -rw-r--r-- | src/flagd-ui/lib/flagd_ui_web/controllers/error_json.ex | 24 | ||||
| -rw-r--r-- | src/flagd-ui/lib/flagd_ui_web/controllers/feature_controller.ex | 12 | ||||
| -rw-r--r-- | src/flagd-ui/lib/flagd_ui_web/controllers/page_html.ex | 13 | ||||
| -rw-r--r-- | src/flagd-ui/lib/flagd_ui_web/endpoint.ex | 50 | ||||
| -rw-r--r-- | src/flagd-ui/lib/flagd_ui_web/gettext.ex | 28 | ||||
| -rw-r--r-- | src/flagd-ui/lib/flagd_ui_web/live/advanced_editor.ex | 92 | ||||
| -rw-r--r-- | src/flagd-ui/lib/flagd_ui_web/live/dashboard.ex | 66 | ||||
| -rw-r--r-- | src/flagd-ui/lib/flagd_ui_web/router.ex | 50 | ||||
| -rw-r--r-- | src/flagd-ui/lib/flagd_ui_web/telemetry.ex | 73 |
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 |
