From 176d62a3c7225bb75206b312849fe5728220bb11 Mon Sep 17 00:00:00 2001 From: Andrew Boessen Date: Wed, 27 May 2026 23:39:48 -0500 Subject: [PATCH 01/11] add liveview web ui --- .../lib/dust_api/handlers/status_handler.ex | 2 + apps/dust_cli/lib/dust_cli.ex | 11 +- apps/dust_cli/lib/dust_cli/commands/ui.ex | 105 ++++++ apps/dust_core/lib/dust_core/fitness.ex | 16 + .../lib/dust_daemon/disk_manager.ex | 11 + apps/dust_ui/assets/css/app.css | 9 + apps/dust_ui/assets/js/app.js | 21 ++ apps/dust_ui/assets/tailwind.config.js | 22 ++ apps/dust_ui/assets/vendor/topbar.js | 153 ++++++++ apps/dust_ui/lib/dust_ui.ex | 77 ++++ apps/dust_ui/lib/dust_ui/application.ex | 99 +++++ apps/dust_ui/lib/dust_ui/auth/keystore.ex | 34 ++ apps/dust_ui/lib/dust_ui/auth/live_auth.ex | 25 ++ apps/dust_ui/lib/dust_ui/auth/session.ex | 82 +++++ .../lib/dust_ui/components/core_components.ex | 157 ++++++++ .../lib/dust_ui/components/error_html.ex | 8 + .../lib/dust_ui/components/error_json.ex | 7 + .../lib/dust_ui/components/file_table.ex | 240 +++++++++++++ apps/dust_ui/lib/dust_ui/components/format.ex | 69 ++++ .../dust_ui/lib/dust_ui/components/layouts.ex | 8 + .../dust_ui/components/layouts/app.html.heex | 29 ++ .../dust_ui/components/layouts/root.html.heex | 18 + .../lib/dust_ui/components/peer_card.ex | 49 +++ .../lib/dust_ui/components/stat_card.ex | 26 ++ .../controllers/download_controller.ex | 52 +++ .../dust_ui/controllers/session_controller.ex | 66 ++++ .../lib/dust_ui/controllers/session_html.ex | 6 + .../controllers/session_html/new.html.heex | 40 +++ apps/dust_ui/lib/dust_ui/endpoint.ex | 41 +++ apps/dust_ui/lib/dust_ui/live/files_live.ex | 339 ++++++++++++++++++ apps/dust_ui/lib/dust_ui/live/index_live.ex | 100 ++++++ apps/dust_ui/lib/dust_ui/live/peers_live.ex | 111 ++++++ apps/dust_ui/lib/dust_ui/live/storage_live.ex | 149 ++++++++ apps/dust_ui/lib/dust_ui/router.ex | 46 +++ apps/dust_ui/lib/dust_ui/telemetry.ex | 50 +++ apps/dust_ui/mix.exs | 39 +- .../lib/dust_utilities/config.ex | 20 ++ config/config.exs | 42 ++- config/dev.exs | 22 ++ config/prod.exs | 7 + config/runtime.exs | 8 + config/test.exs | 9 +- mix.exs | 3 +- mix.lock | 14 +- 44 files changed, 2423 insertions(+), 19 deletions(-) create mode 100644 apps/dust_cli/lib/dust_cli/commands/ui.ex create mode 100644 apps/dust_ui/assets/css/app.css create mode 100644 apps/dust_ui/assets/js/app.js create mode 100644 apps/dust_ui/assets/tailwind.config.js create mode 100644 apps/dust_ui/assets/vendor/topbar.js create mode 100644 apps/dust_ui/lib/dust_ui/application.ex create mode 100644 apps/dust_ui/lib/dust_ui/auth/keystore.ex create mode 100644 apps/dust_ui/lib/dust_ui/auth/live_auth.ex create mode 100644 apps/dust_ui/lib/dust_ui/auth/session.ex create mode 100644 apps/dust_ui/lib/dust_ui/components/core_components.ex create mode 100644 apps/dust_ui/lib/dust_ui/components/error_html.ex create mode 100644 apps/dust_ui/lib/dust_ui/components/error_json.ex create mode 100644 apps/dust_ui/lib/dust_ui/components/file_table.ex create mode 100644 apps/dust_ui/lib/dust_ui/components/format.ex create mode 100644 apps/dust_ui/lib/dust_ui/components/layouts.ex create mode 100644 apps/dust_ui/lib/dust_ui/components/layouts/app.html.heex create mode 100644 apps/dust_ui/lib/dust_ui/components/layouts/root.html.heex create mode 100644 apps/dust_ui/lib/dust_ui/components/peer_card.ex create mode 100644 apps/dust_ui/lib/dust_ui/components/stat_card.ex create mode 100644 apps/dust_ui/lib/dust_ui/controllers/download_controller.ex create mode 100644 apps/dust_ui/lib/dust_ui/controllers/session_controller.ex create mode 100644 apps/dust_ui/lib/dust_ui/controllers/session_html.ex create mode 100644 apps/dust_ui/lib/dust_ui/controllers/session_html/new.html.heex create mode 100644 apps/dust_ui/lib/dust_ui/endpoint.ex create mode 100644 apps/dust_ui/lib/dust_ui/live/files_live.ex create mode 100644 apps/dust_ui/lib/dust_ui/live/index_live.ex create mode 100644 apps/dust_ui/lib/dust_ui/live/peers_live.ex create mode 100644 apps/dust_ui/lib/dust_ui/live/storage_live.ex create mode 100644 apps/dust_ui/lib/dust_ui/router.ex create mode 100644 apps/dust_ui/lib/dust_ui/telemetry.ex diff --git a/apps/dust_api/lib/dust_api/handlers/status_handler.ex b/apps/dust_api/lib/dust_api/handlers/status_handler.ex index dd3c15c..d3c1b53 100644 --- a/apps/dust_api/lib/dust_api/handlers/status_handler.ex +++ b/apps/dust_api/lib/dust_api/handlers/status_handler.ex @@ -20,6 +20,8 @@ defmodule Dust.Api.Handlers.StatusHandler do disk: disk_status(), network: network, persist_dir: Dust.Utilities.Config.persist_dir(), + ui_port: Dust.Utilities.Config.ui_port(), + ui_bind: Dust.Utilities.Config.ui_bind(), uptime_ms: :erlang.statistics(:wall_clock) |> elem(0), version: "0.1.5" } diff --git a/apps/dust_cli/lib/dust_cli.ex b/apps/dust_cli/lib/dust_cli.ex index 3da4304..f2d73a7 100644 --- a/apps/dust_cli/lib/dust_cli.ex +++ b/apps/dust_cli/lib/dust_cli.ex @@ -46,6 +46,9 @@ defmodule Dust.CLI do gc stats Show garbage collection statistics gc sweep Trigger a manual GC sweep + ui open Open the web UI in your browser + ui status Show the web UI URL and reachability + help Show this help message version Show version @@ -62,7 +65,7 @@ defmodule Dust.CLI do @version "0.1.5" # Commands that DO NOT require Tailscale connectivity - @no_network_required ~w(init status auth daemon unlock lock config help version) + @no_network_required ~w(init status auth daemon unlock lock config help version ui) @doc false def run(args) do @@ -197,6 +200,8 @@ defmodule Dust.CLI do defp dispatch({config, ["gc" | args]}), do: Commands.Gc.run(config, args) + defp dispatch({config, ["ui" | args]}), do: Commands.Ui.run(config, args) + defp dispatch({_config, ["version" | _]}) do IO.puts("dustctl #{@version}") 0 @@ -255,6 +260,10 @@ defmodule Dust.CLI do {"gc stats", "Show garbage collection statistics"}, {"gc sweep", "Trigger a manual GC sweep"} ]}, + {"Web UI", [ + {"ui open", "Open the web UI in your browser"}, + {"ui status", "Show the web UI URL and reachability"} + ]}, {"Other", [ {"help", "Show this help message"}, {"version", "Show version"} diff --git a/apps/dust_cli/lib/dust_cli/commands/ui.ex b/apps/dust_cli/lib/dust_cli/commands/ui.ex new file mode 100644 index 0000000..4d98cc1 --- /dev/null +++ b/apps/dust_cli/lib/dust_cli/commands/ui.ex @@ -0,0 +1,105 @@ +defmodule Dust.CLI.Commands.Ui do + @moduledoc false + + alias Dust.CLI.{Client, Formatter} + + def run(config, []), do: open(config, []) + def run(config, ["open" | args]), do: open(config, args) + def run(config, ["status" | args]), do: status(config, args) + + def run(_config, [unknown | _]) do + Formatter.error("Unknown ui subcommand: #{unknown}") + Formatter.info("Available: open, status") + 1 + end + + defp open(config, _args) do + case ui_url(config) do + {:ok, url} -> + case launch_browser(url) do + :ok -> + Formatter.success("Opened #{url}") + 0 + + {:error, reason} -> + Formatter.warning("Could not open browser: #{reason}") + Formatter.info(url) + 0 + end + + {:error, reason} -> + Formatter.error(reason) + 1 + end + end + + defp status(config, _args) do + case ui_url(config) do + {:ok, url} -> + Formatter.kv([{"URL", url}, {"Reachable", reachability(url)}]) + 0 + + {:error, reason} -> + Formatter.error(reason) + 1 + end + end + + # ── URL resolution ─────────────────────────────────────────────────── + + defp ui_url(config) do + case Client.get(config, "/api/v1/status") do + {200, {:ok, body}} -> + port = Map.get(body, "ui_port") || 4885 + bind = Map.get(body, "ui_bind") || "127.0.0.1" + host = display_host(bind) + {:ok, "http://#{host}:#{port}"} + + {:error, {:failed_connect, _}} -> + {:error, "Daemon is unreachable. Start it with `dustctl daemon start`."} + + other -> + {:error, "Could not query daemon status: #{inspect(other)}"} + end + end + + # Browsers don't accept "0.0.0.0" — substitute with localhost. + defp display_host("0.0.0.0"), do: "127.0.0.1" + defp display_host(""), do: "127.0.0.1" + defp display_host(bind), do: bind + + # ── Browser launching ──────────────────────────────────────────────── + + defp launch_browser(url) do + {cmd, args} = browser_open_command(url) + + case System.cmd(cmd, args, stderr_to_stdout: true) do + {_out, 0} -> :ok + {out, status} -> {:error, "#{cmd} exited #{status}: #{String.trim(out)}"} + end + rescue + e in ErlangError -> {:error, Exception.message(e)} + end + + defp browser_open_command(url) do + case :os.type() do + {:unix, :darwin} -> {"open", [url]} + {:win32, _} -> {"cmd", ["/c", "start", "", url]} + {:unix, _} -> {"xdg-open", [url]} + end + end + + # ── Reachability check ─────────────────────────────────────────────── + + defp reachability(url) do + request = {String.to_charlist(url), []} + + case :httpc.request(:head, request, [timeout: 1000, connect_timeout: 500], []) do + {:ok, {{_, status, _}, _, _}} when status in 200..399 -> "yes (HTTP #{status})" + {:ok, {{_, status, _}, _, _}} -> "responded HTTP #{status}" + {:error, reason} -> "no (#{inspect(reason)})" + end + rescue + _ -> "no" + end +end diff --git a/apps/dust_core/lib/dust_core/fitness.ex b/apps/dust_core/lib/dust_core/fitness.ex index fe9b1a4..6b795c4 100644 --- a/apps/dust_core/lib/dust_core/fitness.ex +++ b/apps/dust_core/lib/dust_core/fitness.ex @@ -212,4 +212,20 @@ defmodule Dust.Core.Fitness do def record(node_id, observation) when is_atom(node_id) do ModelStore.update(node_id, observation) end + + @doc """ + List every node for which a fitness model is currently stored. + + Returns `[{node(), NodeEMA.t()}]`. Order is unspecified. Reads directly + from the public ETS table, so it is lock-free and safe at any time. + Nodes that have never been interacted with are NOT in the list — call + `score/1` to get a default model for an unseen node. + """ + @spec list() :: [{node(), NodeEMA.t()}] + def list do + case :ets.whereis(:fitness_models) do + :undefined -> [] + _tid -> :ets.tab2list(:fitness_models) + end + end end diff --git a/apps/dust_daemon/lib/dust_daemon/disk_manager.ex b/apps/dust_daemon/lib/dust_daemon/disk_manager.ex index e425aaf..5e99c5c 100644 --- a/apps/dust_daemon/lib/dust_daemon/disk_manager.ex +++ b/apps/dust_daemon/lib/dust_daemon/disk_manager.ex @@ -58,6 +58,17 @@ defmodule Dust.Daemon.DiskManager do end end + @doc """ + Current size of the local shard store on disk, in bytes. + + Walks the storage backend directory tree. Cheap enough to call on a + dashboard refresh tick (10s); avoid hot paths. + """ + @spec usage_bytes() :: non_neg_integer() + def usage_bytes do + dir_size(Dust.Utilities.File.storage_db_dir()) + end + @spec dir_size(String.t()) :: non_neg_integer() defp dir_size(path) do case File.ls(path) do diff --git a/apps/dust_ui/assets/css/app.css b/apps/dust_ui/assets/css/app.css new file mode 100644 index 0000000..da60a94 --- /dev/null +++ b/apps/dust_ui/assets/css/app.css @@ -0,0 +1,9 @@ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + +/* App-wide tweaks live here. */ +html, +body { + background-color: rgb(250 250 250); +} diff --git a/apps/dust_ui/assets/js/app.js b/apps/dust_ui/assets/js/app.js new file mode 100644 index 0000000..cf0bf1d --- /dev/null +++ b/apps/dust_ui/assets/js/app.js @@ -0,0 +1,21 @@ +import "phoenix_html" +import { Socket } from "phoenix" +import { LiveSocket } from "phoenix_live_view" +import topbar from "../vendor/topbar" + +const csrfToken = document + .querySelector("meta[name='csrf-token']") + .getAttribute("content") + +const liveSocket = new LiveSocket("/live", Socket, { + longPollFallbackMs: 2500, + params: { _csrf_token: csrfToken }, +}) + +// Page-load progress bar +topbar.config({ barColors: { 0: "#18181b" }, shadowColor: "rgba(0, 0, 0, .3)" }) +window.addEventListener("phx:page-loading-start", () => topbar.show(300)) +window.addEventListener("phx:page-loading-stop", () => topbar.hide()) + +liveSocket.connect() +window.liveSocket = liveSocket diff --git a/apps/dust_ui/assets/tailwind.config.js b/apps/dust_ui/assets/tailwind.config.js new file mode 100644 index 0000000..937a920 --- /dev/null +++ b/apps/dust_ui/assets/tailwind.config.js @@ -0,0 +1,22 @@ +const plugin = require("tailwindcss/plugin") + +module.exports = { + content: [ + "./js/**/*.js", + "../lib/dust_ui.ex", + "../lib/dust_ui/**/*.{ex,heex}", + ], + theme: { + extend: { + colors: { + brand: "#18181b", + }, + }, + }, + plugins: [ + plugin(({ addVariant }) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])), + plugin(({ addVariant }) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), + plugin(({ addVariant }) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), + plugin(({ addVariant }) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), + ], +} diff --git a/apps/dust_ui/assets/vendor/topbar.js b/apps/dust_ui/assets/vendor/topbar.js new file mode 100644 index 0000000..e9bc3d2 --- /dev/null +++ b/apps/dust_ui/assets/vendor/topbar.js @@ -0,0 +1,153 @@ +/** + * @license MIT + * topbar 2.0.0, 2023-02-04 + * https://buunguyen.github.io/topbar + * Copyright (c) 2021 Buu Nguyen + */ +(function (window, document) { + "use strict"; + + // https://gist.github.com/paulirish/1579671 + (function () { + var lastTime = 0; + var vendors = ["ms", "moz", "webkit", "o"]; + for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { + window.requestAnimationFrame = window[vendors[x] + "RequestAnimationFrame"]; + window.cancelAnimationFrame = + window[vendors[x] + "CancelAnimationFrame"] || + window[vendors[x] + "CancelRequestAnimationFrame"]; + } + if (!window.requestAnimationFrame) + window.requestAnimationFrame = function (callback) { + var currTime = new Date().getTime(); + var timeToCall = Math.max(0, 16 - (currTime - lastTime)); + var id = window.setTimeout(function () { + callback(currTime + timeToCall); + }, timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + if (!window.cancelAnimationFrame) + window.cancelAnimationFrame = function (id) { + clearTimeout(id); + }; + })(); + + var canvas, + progressTimerId, + fadeTimerId, + currentProgress, + showing, + addEvent = function (elem, type, handler) { + if (elem.addEventListener) elem.addEventListener(type, handler, false); + else if (elem.attachEvent) elem.attachEvent("on" + type, handler); + else elem["on" + type] = handler; + }, + options = { + autoRun: true, + barThickness: 3, + barColors: { 0: "rgba(26, 188, 156, .9)", ".25": "rgba(52, 152, 219, .9)", ".50": "rgba(241, 196, 15, .9)", ".75": "rgba(230, 126, 34, .9)", "1.0": "rgba(211, 84, 0, .9)" }, + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, .6)", + className: null, + }, + repaint = function () { + canvas.width = window.innerWidth; + canvas.height = options.barThickness * 5; + var ctx = canvas.getContext("2d"); + ctx.shadowBlur = options.shadowBlur; + ctx.shadowColor = options.shadowColor; + var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + for (var stop in options.barColors) + lineGradient.addColorStop(stop, options.barColors[stop]); + ctx.lineWidth = options.barThickness; + ctx.beginPath(); + ctx.moveTo(0, options.barThickness / 2); + ctx.lineTo( + Math.ceil(currentProgress * canvas.width), + options.barThickness / 2 + ); + ctx.strokeStyle = lineGradient; + ctx.stroke(); + }, + createCanvas = function () { + canvas = document.createElement("canvas"); + var style = canvas.style; + style.position = "fixed"; + style.top = style.left = style.right = style.margin = style.padding = 0; + style.zIndex = 100001; + style.display = "none"; + if (options.className) canvas.classList.add(options.className); + document.body.appendChild(canvas); + addEvent(window, "resize", repaint); + }, + topbar = { + config: function (opts) { + for (var key in opts) + if (options.hasOwnProperty(key)) options[key] = opts[key]; + }, + show: function (delay) { + if (showing) return; + if (delay) { + if (progressTimerId) return; + progressTimerId = setTimeout(() => topbar.show(), delay); + } else { + showing = true; + if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); + if (!canvas) createCanvas(); + canvas.style.opacity = 1; + canvas.style.display = "block"; + topbar.progress(0); + if (options.autoRun) { + (function loop() { + progressTimerId = window.requestAnimationFrame(loop); + topbar.progress( + "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) + ); + })(); + } + } + }, + progress: function (to) { + if (typeof to === "undefined") return currentProgress; + if (typeof to === "string") { + to = + (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 + ? currentProgress + : 0) + parseFloat(to); + } + currentProgress = to > 1 ? 1 : to; + repaint(); + return currentProgress; + }, + hide: function () { + clearTimeout(progressTimerId); + progressTimerId = null; + if (!showing) return; + showing = false; + if (canvas != null) { + (function loop() { + if (topbar.progress("+.1") >= 1) { + canvas.style.opacity -= 0.05; + if (canvas.style.opacity <= 0.05) { + canvas.style.display = "none"; + fadeTimerId = null; + return; + } + } + fadeTimerId = window.requestAnimationFrame(loop); + })(); + } + }, + }; + + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar; + } else if (typeof define === "function" && define.amd) { + define(function () { + return topbar; + }); + } else { + this.topbar = topbar; + } +}.call(this, window, document)); diff --git a/apps/dust_ui/lib/dust_ui.ex b/apps/dust_ui/lib/dust_ui.ex index cba96da..8588a8f 100644 --- a/apps/dust_ui/lib/dust_ui.ex +++ b/apps/dust_ui/lib/dust_ui.ex @@ -5,5 +5,82 @@ defmodule Dust.Ui do Provides the front-end through which users interact with the distributed file system — managing files, unlocking the key store, and monitoring node status. + + This module also hosts the imports used by controllers, LiveViews, and + components in `apps/dust_ui/lib/dust_ui/`. """ + + def static_paths, + do: ~w(assets fonts images favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router, helpers: false + + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def controller do + quote do + use Phoenix.Controller, + formats: [:html, :json], + layouts: [html: Dust.Ui.Layouts] + + import Plug.Conn + unquote(verified_routes()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, layout: {Dust.Ui.Layouts, :app} + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + import Phoenix.Controller, only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + import Phoenix.HTML + + import Dust.Ui.CoreComponents + alias Phoenix.LiveView.JS + + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: Dust.Ui.Endpoint, + router: Dust.Ui.Router, + statics: Dust.Ui.static_paths() + end + end + + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end end diff --git a/apps/dust_ui/lib/dust_ui/application.ex b/apps/dust_ui/lib/dust_ui/application.ex new file mode 100644 index 0000000..a386aac --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/application.ex @@ -0,0 +1,99 @@ +defmodule Dust.Ui.Application do + @moduledoc """ + OTP application for the Dust web UI (Phoenix LiveView). + + Resolves the runtime bind/port from `Dust.Utilities.Config`, ensures a + persistent `secret_key_base` exists on disk, then starts the Phoenix + PubSub server, telemetry supervisor, and the endpoint under supervision. + """ + + use Application + + require Logger + + @impl true + def start(_type, _args) do + configure_endpoint!() + + children = [ + Dust.Ui.Telemetry, + {Phoenix.PubSub, name: Dust.Ui.PubSub}, + Dust.Ui.Endpoint + ] + + Logger.info( + "Dust.Ui: starting on http://#{Dust.Utilities.Config.ui_bind()}:#{Dust.Utilities.Config.ui_port()}" + ) + + opts = [strategy: :one_for_one, name: Dust.Ui.Supervisor] + Supervisor.start_link(children, opts) + end + + @impl true + def config_change(changed, _new, removed) do + Dust.Ui.Endpoint.config_change(changed, removed) + :ok + end + + # Resolve runtime config (port, bind, secret_key_base) into the endpoint env + # before the endpoint child boots. + defp configure_endpoint!() do + port = Dust.Utilities.Config.ui_port() + ip = parse_bind(Dust.Utilities.Config.ui_bind()) + secret = ensure_secret_key_base!() + + existing = Application.get_env(:dust_ui, Dust.Ui.Endpoint, []) + + http = + existing + |> Keyword.get(:http, []) + |> Keyword.put(:ip, ip) + |> Keyword.put(:port, port) + + merged = + existing + |> Keyword.put(:http, http) + |> Keyword.put(:secret_key_base, secret) + # The UI exists to be served — start the HTTP listener unconditionally. + # Test env keeps :server false unless explicitly opted in to avoid port + # collisions during async test runs. + |> Keyword.put_new(:server, true) + + Application.put_env(:dust_ui, Dust.Ui.Endpoint, merged) + end + + defp parse_bind(bind_str) do + case :inet.parse_address(to_charlist(bind_str)) do + {:ok, ip} -> ip + {:error, _} -> {127, 0, 0, 1} + end + end + + # Persist a 64-byte hex secret to /ui_secret_key_base on first + # boot. Reused on subsequent boots so signed sessions survive restarts. + defp ensure_secret_key_base!() do + path = Path.join(Dust.Utilities.Config.persist_dir(), "ui_secret_key_base") + + case File.read(path) do + {:ok, contents} -> + secret = String.trim(contents) + + if byte_size(secret) >= 64 do + secret + else + write_secret!(path) + end + + {:error, :enoent} -> + write_secret!(path) + end + end + + defp write_secret!(path) do + secret = 48 |> :crypto.strong_rand_bytes() |> Base.encode64() + File.mkdir_p!(Path.dirname(path)) + File.write!(path, secret) + _ = File.chmod(path, 0o600) + secret + end +end diff --git a/apps/dust_ui/lib/dust_ui/auth/keystore.ex b/apps/dust_ui/lib/dust_ui/auth/keystore.ex new file mode 100644 index 0000000..4feca0e --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/auth/keystore.ex @@ -0,0 +1,34 @@ +defmodule Dust.Ui.Auth.Keystore do + @moduledoc """ + Thin wrapper around `Dust.Core.KeyStore` for the web UI. + + Mirrors the unlock/lock semantics of `Dust.Api.Handlers.KeystoreHandler` so + that logging in through the UI has the same effect as `dustctl unlock`. + """ + + @doc """ + Attempts to unlock the keystore with the given password. + + Returns `:ok` on success, `{:error, :invalid_password}` for a wrong + password, or `{:error, reason}` for anything else. + """ + @spec unlock(String.t()) :: :ok | {:error, :invalid_password | term()} + def unlock(password) when is_binary(password) and password != "" do + case Dust.Core.KeyStore.unlock(password) do + :ok -> :ok + {:error, :decrypt_failed} -> {:error, :invalid_password} + {:error, :already_unlocked} -> :ok + {:error, reason} -> {:error, reason} + end + end + + def unlock(_), do: {:error, :invalid_password} + + @doc "Locks the keystore." + @spec lock() :: :ok + def lock, do: Dust.Core.KeyStore.lock() + + @doc "Returns true if the keystore is currently unlocked." + @spec unlocked?() :: boolean() + def unlocked?, do: Dust.Core.KeyStore.has_key?() +end diff --git a/apps/dust_ui/lib/dust_ui/auth/live_auth.ex b/apps/dust_ui/lib/dust_ui/auth/live_auth.ex new file mode 100644 index 0000000..c584633 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/auth/live_auth.ex @@ -0,0 +1,25 @@ +defmodule Dust.Ui.Auth.LiveAuth do + @moduledoc """ + LiveView `on_mount` hook that enforces an authenticated session. + + Used in a `live_session` block in the router. Halts the mount and + redirects to `/login` when: + + * the session does not carry `:authenticated`, OR + * the keystore has been locked out of band (CLI / API). + """ + + import Phoenix.LiveView + use Phoenix.VerifiedRoutes, + endpoint: Dust.Ui.Endpoint, + router: Dust.Ui.Router, + statics: Dust.Ui.static_paths() + + def on_mount(:default, _params, session, socket) do + if session["authenticated"] == true and Dust.Ui.Auth.Keystore.unlocked?() do + {:cont, socket} + else + {:halt, redirect(socket, to: ~p"/login")} + end + end +end diff --git a/apps/dust_ui/lib/dust_ui/auth/session.ex b/apps/dust_ui/lib/dust_ui/auth/session.ex new file mode 100644 index 0000000..fedb4d0 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/auth/session.ex @@ -0,0 +1,82 @@ +defmodule Dust.Ui.Auth.Session do + @moduledoc """ + Session helpers and plugs for UI authentication. + + Authentication is bound to the keystore: a user is considered logged in + while the session carries `:authenticated` AND the keystore reports + `Dust.Ui.Auth.Keystore.unlocked?/0`. If the keystore is locked from + another source (CLI, API), the next request bounces back to `/login`. + """ + + import Plug.Conn + use Phoenix.VerifiedRoutes, + endpoint: Dust.Ui.Endpoint, + router: Dust.Ui.Router, + statics: Dust.Ui.static_paths() + + @session_key :authenticated + + @doc "Marks the connection as logged in after a successful unlock." + @spec log_in(Plug.Conn.t()) :: Plug.Conn.t() + def log_in(conn) do + conn + |> renew_session() + |> put_session(@session_key, true) + end + + @doc "Clears any authentication state from the session." + @spec log_out(Plug.Conn.t()) :: Plug.Conn.t() + def log_out(conn) do + conn + |> configure_session(drop: true) + end + + @doc "Returns true if the session is authenticated and the keystore is unlocked." + @spec authenticated?(Plug.Conn.t()) :: boolean() + def authenticated?(conn) do + get_session(conn, @session_key) == true and Dust.Ui.Auth.Keystore.unlocked?() + end + + # ── Plug behaviour ─────────────────────────────────────────────────── + + @doc false + def init(action) when action in [:require_authenticated, :redirect_if_authenticated], + do: action + + @doc false + def call(conn, :require_authenticated), do: require_authenticated(conn, []) + def call(conn, :redirect_if_authenticated), do: redirect_if_authenticated(conn, []) + + # ── Plug actions ───────────────────────────────────────────────────── + + @doc "Plug: redirect to /login if the session is not authenticated." + def require_authenticated(conn, _opts) do + if authenticated?(conn) do + conn + else + conn + |> log_out() + |> Phoenix.Controller.redirect(to: ~p"/login") + |> halt() + end + end + + @doc """ + Plug: redirect already-authenticated visitors away from the login page. + """ + def redirect_if_authenticated(conn, _opts) do + if authenticated?(conn) do + conn + |> Phoenix.Controller.redirect(to: ~p"/") + |> halt() + else + conn + end + end + + defp renew_session(conn) do + conn + |> configure_session(renew: true) + |> clear_session() + end +end diff --git a/apps/dust_ui/lib/dust_ui/components/core_components.ex b/apps/dust_ui/lib/dust_ui/components/core_components.ex new file mode 100644 index 0000000..5ecc710 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/components/core_components.ex @@ -0,0 +1,157 @@ +defmodule Dust.Ui.CoreComponents do + @moduledoc """ + Minimal core component library for the Dust web UI. + + Provides building blocks (`button`, `input`, `flash`) used across the + login page and dashboards. Styling is Tailwind utility classes. + """ + use Phoenix.Component + + alias Phoenix.LiveView.JS + + @doc """ + Renders a flash notice (`:info` or `:error`). + """ + attr :kind, :atom, values: [:info, :error], doc: "the kind of flash" + attr :flash, :map, default: %{}, doc: "the map of flash messages" + attr :title, :string, default: nil + attr :id, :string, default: nil + slot :inner_block + + def flash(assigns) do + ~H""" + + """ + end + + @doc "Renders a group of flash messages from `@flash`." + attr :flash, :map, required: true + + def flash_group(assigns) do + ~H""" + <.flash kind={:info} title="Heads up" flash={@flash} /> + <.flash kind={:error} title="Error" flash={@flash} /> + """ + end + + @doc """ + Renders a styled button. Either drives a `phx-click` event or, with + `type="submit"`, submits the surrounding form. + """ + attr :type, :string, default: "button" + attr :class, :string, default: nil + attr :rest, :global, include: ~w(disabled form name value) + slot :inner_block, required: true + + def button(assigns) do + ~H""" + + """ + end + + @doc """ + Renders a labelled form input. + """ + attr :id, :any, default: nil + attr :name, :any + attr :label, :string, default: nil + attr :value, :any, default: nil + attr :type, :string, default: "text" + attr :errors, :list, default: [] + attr :rest, :global, include: ~w(autocomplete placeholder required) + + def input(assigns) do + ~H""" +
+ + +

{msg}

+
+ """ + end + + @doc """ + Hides an element with a Phoenix LiveView JS transition. + """ + def hide(js \\ %JS{}, selector) do + JS.hide(js, + to: selector, + transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"} + ) + end + + @doc """ + A simple modal overlay. Dismiss-on-backdrop-click sends `on_close` to + the parent LiveView. + """ + attr :title, :string, default: nil + attr :on_close, :string, default: "close_modal" + slot :inner_block, required: true + + def modal(assigns) do + ~H""" + + """ + end +end diff --git a/apps/dust_ui/lib/dust_ui/components/error_html.ex b/apps/dust_ui/lib/dust_ui/components/error_html.ex new file mode 100644 index 0000000..cbcd176 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/components/error_html.ex @@ -0,0 +1,8 @@ +defmodule Dust.Ui.ErrorHTML do + @moduledoc false + use Dust.Ui, :html + + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/apps/dust_ui/lib/dust_ui/components/error_json.ex b/apps/dust_ui/lib/dust_ui/components/error_json.ex new file mode 100644 index 0000000..862c555 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/components/error_json.ex @@ -0,0 +1,7 @@ +defmodule Dust.Ui.ErrorJSON do + @moduledoc false + + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/apps/dust_ui/lib/dust_ui/components/file_table.ex b/apps/dust_ui/lib/dust_ui/components/file_table.ex new file mode 100644 index 0000000..d729b76 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/components/file_table.ex @@ -0,0 +1,240 @@ +defmodule Dust.Ui.FileTable do + @moduledoc false + use Phoenix.Component + + use Phoenix.VerifiedRoutes, + endpoint: Dust.Ui.Endpoint, + router: Dust.Ui.Router, + statics: Dust.Ui.static_paths() + + import Dust.Ui.CoreComponents + + alias Dust.Ui.Format + + @doc """ + Breadcrumb trail. `trail` is a list of `%{id, name}` from root to + current. The last entry is rendered un-linked. + """ + attr :trail, :list, required: true + + def breadcrumbs(assigns) do + ~H""" + + """ + end + + @doc """ + Renders the directory/file table. + """ + attr :dirs, :list, required: true + attr :files, :list, required: true + attr :progress, :map, default: %{} + + def file_table(assigns) do + ~H""" +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Name + + Size + + Created + + Actions +
+ This directory is empty. +
+ <.link patch={~p"/files/#{dir.id}"} class="flex items-center gap-2 font-medium text-zinc-900 hover:underline"> + 📁 + {dir.name} + + {Format.relative_time(dir[:created_at])} +
+ + +
+
+
+ 📄 + {file.name} +
+
+
+
+
+
+

+ {progress_label(p)} · {p.chunk}/{p.total} +

+
+
+ {Format.bytes(parse_int(Map.get(file, :size)))} + {Format.relative_time(file[:created_at])} +
+ <.link + href={~p"/download/#{file.id}"} + class="text-zinc-700 hover:text-zinc-900" + > + Download + + + +
+
+
+ """ + end + + defp parse_int(nil), do: nil + defp parse_int(n) when is_integer(n), do: n + + defp parse_int(s) when is_binary(s) do + case Integer.parse(s) do + {n, _} -> n + _ -> nil + end + end + + defp parse_int(_), do: nil + + defp progress_percent(%{chunk: c, total: t}) when is_integer(t) and t > 0, + do: trunc(c * 100 / t) |> min(100) |> max(0) + + defp progress_percent(_), do: 0 + + defp progress_label(%{kind: :upload}), do: "Uploading" + defp progress_label(%{kind: :download}), do: "Downloading" + defp progress_label(_), do: "" + + @doc "Renders the drag-and-drop upload dropzone with a submit form." + attr :uploads, :map, required: true + + def upload_dropzone(assigns) do + ~H""" +
+

+ Drop files here or + +

+ + <%= for entry <- @uploads.files.entries do %> +
+

{entry.client_name}

+
+
+
+

+ {msg} +

+
+ <% end %> + +
+ <.button type="submit" class="bg-emerald-600 hover:bg-emerald-700">Upload +
+
+ """ + end + + defp upload_errors_for_entry(config, entry) do + case Phoenix.Component.upload_errors(config, entry) do + [] -> nil + [err | _] -> humanize_upload_error(err) + end + end + + defp humanize_upload_error(:too_large), do: "File is too large." + defp humanize_upload_error(:not_accepted), do: "File type not accepted." + defp humanize_upload_error(:too_many_files), do: "Too many files." + defp humanize_upload_error(err), do: inspect(err) +end diff --git a/apps/dust_ui/lib/dust_ui/components/format.ex b/apps/dust_ui/lib/dust_ui/components/format.ex new file mode 100644 index 0000000..466f691 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/components/format.ex @@ -0,0 +1,69 @@ +defmodule Dust.Ui.Format do + @moduledoc """ + Display-format helpers shared across LiveViews and components. + """ + + @units ["B", "KB", "MB", "GB", "TB", "PB"] + + @doc """ + Formats a byte count as a human-readable string (1.4 GB, 532 KB, …). + """ + @spec bytes(integer() | nil) :: String.t() + def bytes(nil), do: "—" + def bytes(0), do: "0 B" + + def bytes(n) when is_integer(n) and n > 0 do + exp = min(trunc(:math.log(n) / :math.log(1024)), length(@units) - 1) + val = n / :math.pow(1024, exp) + unit = Enum.at(@units, exp) + "#{:erlang.float_to_binary(val, decimals: precision(val))} #{unit}" + end + + def bytes(_), do: "—" + + defp precision(v) when v >= 100, do: 0 + defp precision(v) when v >= 10, do: 1 + defp precision(_), do: 2 + + @doc """ + Formats a percentage (0.0..1.0) clamped and rounded. + """ + @spec percent(float() | integer() | nil, integer()) :: String.t() + def percent(ratio, decimals \\ 0) + def percent(nil, _decimals), do: "—" + def percent(0, _decimals), do: "0%" + + def percent(ratio, decimals) when is_number(ratio) do + clamped = ratio |> min(1.0) |> max(0.0) + "#{:erlang.float_to_binary(clamped * 100.0, decimals: decimals)}%" + end + + @doc """ + Returns a relative timestamp like "12s ago", "3m ago", "2h ago". + Accepts a DateTime, NaiveDateTime, integer (unix seconds), or nil. + """ + @spec relative_time(DateTime.t() | NaiveDateTime.t() | integer() | nil) :: String.t() + def relative_time(nil), do: "never" + + def relative_time(%DateTime{} = dt) do + relative_seconds(DateTime.diff(DateTime.utc_now(), dt)) + end + + def relative_time(%NaiveDateTime{} = ndt) do + now = NaiveDateTime.utc_now() + relative_seconds(NaiveDateTime.diff(now, ndt)) + end + + def relative_time(unix) when is_integer(unix) do + now = System.os_time(:second) + relative_seconds(now - unix) + end + + def relative_time(_), do: "—" + + defp relative_seconds(secs) when secs < 0, do: "in the future" + defp relative_seconds(secs) when secs < 60, do: "#{secs}s ago" + defp relative_seconds(secs) when secs < 3600, do: "#{div(secs, 60)}m ago" + defp relative_seconds(secs) when secs < 86_400, do: "#{div(secs, 3600)}h ago" + defp relative_seconds(secs), do: "#{div(secs, 86_400)}d ago" +end diff --git a/apps/dust_ui/lib/dust_ui/components/layouts.ex b/apps/dust_ui/lib/dust_ui/components/layouts.ex new file mode 100644 index 0000000..3f7f068 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/components/layouts.ex @@ -0,0 +1,8 @@ +defmodule Dust.Ui.Layouts do + @moduledoc """ + Root and application layouts for the Dust web UI. + """ + use Dust.Ui, :html + + embed_templates "layouts/*" +end diff --git a/apps/dust_ui/lib/dust_ui/components/layouts/app.html.heex b/apps/dust_ui/lib/dust_ui/components/layouts/app.html.heex new file mode 100644 index 0000000..ec4b971 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/components/layouts/app.html.heex @@ -0,0 +1,29 @@ +
+
+
+
+ <.link navigate={~p"/"} class="text-lg font-semibold tracking-tight"> + Dust + + +
+ <.link + href={~p"/logout"} + method="delete" + class="text-sm text-zinc-500 hover:text-zinc-900" + > + Lock + +
+
+ + <.flash_group flash={@flash} /> + +
+ {@inner_content} +
+
diff --git a/apps/dust_ui/lib/dust_ui/components/layouts/root.html.heex b/apps/dust_ui/lib/dust_ui/components/layouts/root.html.heex new file mode 100644 index 0000000..7037d16 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/components/layouts/root.html.heex @@ -0,0 +1,18 @@ + + + + + + + + <.live_title default="Dust" suffix=" · Dust"> + {assigns[:page_title]} + + + + + + {@inner_content} + + diff --git a/apps/dust_ui/lib/dust_ui/components/peer_card.ex b/apps/dust_ui/lib/dust_ui/components/peer_card.ex new file mode 100644 index 0000000..187fe94 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/components/peer_card.ex @@ -0,0 +1,49 @@ +defmodule Dust.Ui.PeerCard do + @moduledoc false + use Phoenix.Component + + alias Dust.Ui.Format + + attr :node, :atom, required: true + attr :status, :atom, required: true + attr :seen_at, :any, default: nil + attr :fitness, :map, default: nil + + def peer_card(assigns) do + ~H""" +
+
+
+

{to_string(@node)}

+

+ Last seen {Format.relative_time(@seen_at)} +

+
+ + {to_string(@status)} + +
+ +
+
+
Success
+
{Format.percent(@fitness.success_rate, 0)}
+
+
+
Latency
+
{:erlang.float_to_binary(@fitness.latency_ms / 1.0, decimals: 0)} ms
+
+
+
Bandwidth
+
{:erlang.float_to_binary(@fitness.bandwidth / 1.0, decimals: 1)} Mb/s
+
+
+
+ """ + end +end diff --git a/apps/dust_ui/lib/dust_ui/components/stat_card.ex b/apps/dust_ui/lib/dust_ui/components/stat_card.ex new file mode 100644 index 0000000..2b65609 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/components/stat_card.ex @@ -0,0 +1,26 @@ +defmodule Dust.Ui.StatCard do + @moduledoc false + use Phoenix.Component + + attr :title, :string, required: true + attr :value, :string, required: true + attr :subtitle, :string, default: nil + attr :href, :string, default: nil + + def stat_card(assigns) do + ~H""" +
+

{@title}

+

{@value}

+

{@subtitle}

+ <.link + :if={@href} + navigate={@href} + class="mt-3 inline-block text-sm font-medium text-zinc-700 hover:text-zinc-900" + > + View → + +
+ """ + end +end diff --git a/apps/dust_ui/lib/dust_ui/controllers/download_controller.ex b/apps/dust_ui/lib/dust_ui/controllers/download_controller.ex new file mode 100644 index 0000000..9295c84 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/controllers/download_controller.ex @@ -0,0 +1,52 @@ +defmodule Dust.Ui.DownloadController do + @moduledoc """ + Streams a file out of the Dust network into the HTTP response. + + Mirrors `Dust.Api.Handlers.FsHandler.download/2`: send chunked headers + first, then call `Dust.Daemon.FileSystem.download_stream/2` with a + `write_fn` that pipes each iodata into `Plug.Conn.chunk/2`. If decoding + fails after the first chunk is sent we cannot change status; we just + close the response and clients reject the body via size/checksum. + """ + use Dust.Ui, :controller + + def show(conn, %{"file_id" => file_id}) do + case Dust.Mesh.FileSystem.stat(file_id) do + nil -> + conn + |> put_status(:not_found) + |> text("File not found") + + meta -> + filename = Map.get(meta, :name, file_id) + mime = Map.get(meta, :mime) || "application/octet-stream" + + conn = + conn + |> put_resp_content_type(mime) + |> put_resp_header( + "content-disposition", + ~s(attachment; filename="#{sanitize(filename)}") + ) + |> send_chunked(200) + + case Dust.Daemon.FileSystem.download_stream(file_id, fn iodata -> + case Plug.Conn.chunk(conn, iodata) do + {:ok, _conn} -> :ok + {:error, _} = err -> err + end + end) do + :ok -> conn + {:error, _reason} -> conn + end + end + end + + defp sanitize(name) when is_binary(name) do + name + |> String.replace(~r/[\r\n"\\]/, "") + |> String.slice(0, 255) + end + + defp sanitize(_), do: "download" +end diff --git a/apps/dust_ui/lib/dust_ui/controllers/session_controller.ex b/apps/dust_ui/lib/dust_ui/controllers/session_controller.ex new file mode 100644 index 0000000..c5b83bb --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/controllers/session_controller.ex @@ -0,0 +1,66 @@ +defmodule Dust.Ui.SessionController do + @moduledoc """ + Login / logout for the Dust web UI. + + Authentication is "you know the keystore password" — the controller + delegates to `Dust.Ui.Auth.Keystore` which wraps `Dust.Core.KeyStore`. + """ + use Dust.Ui, :controller + + alias Dust.Ui.Auth.{Keystore, Session} + + def new(conn, _params) do + render(conn, :new, + error: nil, + system_ready?: Dust.Daemon.Readiness.ready?(), + layout: false + ) + end + + def create(conn, %{"password" => password}) do + case Keystore.unlock(password) do + :ok -> + conn + |> Session.log_in() + |> put_flash(:info, "Welcome back.") + |> redirect(to: ~p"/") + + {:error, :invalid_password} -> + conn + |> put_flash(:error, "Invalid password.") + |> render(:new, + error: "Invalid password.", + system_ready?: Dust.Daemon.Readiness.ready?(), + layout: false + ) + + {:error, reason} -> + conn + |> put_flash(:error, "Could not unlock keystore: #{inspect(reason)}.") + |> render(:new, + error: "Could not unlock keystore.", + system_ready?: Dust.Daemon.Readiness.ready?(), + layout: false + ) + end + end + + def create(conn, _params) do + conn + |> put_flash(:error, "Password is required.") + |> render(:new, + error: "Password is required.", + system_ready?: Dust.Daemon.Readiness.ready?(), + layout: false + ) + end + + def delete(conn, _params) do + _ = Keystore.lock() + + conn + |> Session.log_out() + |> put_flash(:info, "Locked.") + |> redirect(to: ~p"/login") + end +end diff --git a/apps/dust_ui/lib/dust_ui/controllers/session_html.ex b/apps/dust_ui/lib/dust_ui/controllers/session_html.ex new file mode 100644 index 0000000..74ff875 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/controllers/session_html.ex @@ -0,0 +1,6 @@ +defmodule Dust.Ui.SessionHTML do + @moduledoc false + use Dust.Ui, :html + + embed_templates "session_html/*" +end diff --git a/apps/dust_ui/lib/dust_ui/controllers/session_html/new.html.heex b/apps/dust_ui/lib/dust_ui/controllers/session_html/new.html.heex new file mode 100644 index 0000000..2f78a5b --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/controllers/session_html/new.html.heex @@ -0,0 +1,40 @@ +
+
+
+

Dust

+

Unlock your node to continue.

+
+ + <%= if @system_ready? do %> + <.form + for={%{}} + as={:session} + action={~p"/login"} + method="post" + class="space-y-4" + > + <.input + name="password" + id="password" + type="password" + label="Keystore password" + required + autocomplete="current-password" + placeholder="••••••••" + /> + +

{@error}

+ + <.button type="submit" class="w-full">Unlock + + <% else %> +
+

Daemon is still starting…

+

+ Waiting for the network to be ready. This page will reload shortly. +

+ +
+ <% end %> +
+
diff --git a/apps/dust_ui/lib/dust_ui/endpoint.ex b/apps/dust_ui/lib/dust_ui/endpoint.ex new file mode 100644 index 0000000..47c6418 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/endpoint.ex @@ -0,0 +1,41 @@ +defmodule Dust.Ui.Endpoint do + use Phoenix.Endpoint, otp_app: :dust_ui + + @session_options [ + store: :cookie, + key: "_dust_ui_key", + signing_salt: "dust_ui_sess", + same_site: "Lax", + max_age: 60 * 60 * 24 + ] + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] + + plug Plug.Static, + at: "/", + from: :dust_ui, + gzip: false, + only: Dust.Ui.static_paths() + + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + end + + 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 Dust.Ui.Router +end diff --git a/apps/dust_ui/lib/dust_ui/live/files_live.ex b/apps/dust_ui/lib/dust_ui/live/files_live.ex new file mode 100644 index 0000000..ddf403f --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/live/files_live.ex @@ -0,0 +1,339 @@ +defmodule Dust.Ui.FilesLive do + @moduledoc """ + File browser. Lists a directory, supports mkdir / rename / rm and + drag-drop upload via `Dust.Daemon.FileSystem.upload/3`. Subscribes to + upload / download progress topics on `Dust.Daemon.Registry`. + """ + use Dust.Ui, :live_view + + import Dust.Ui.FileTable + + alias Dust.Mesh.FileSystem, as: FS + + @max_upload_size 10 * 1024 * 1024 * 1024 + @max_entries 20 + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + _ = Dust.Daemon.FileSystem.subscribe_upload_progress() + _ = Dust.Daemon.FileSystem.subscribe_download_progress() + end + + socket = + socket + |> assign( + page_title: "Files", + modal: nil, + rename_target: nil, + progress: %{}, + flash_error: nil + ) + |> allow_upload(:files, + accept: :any, + max_entries: @max_entries, + max_file_size: @max_upload_size, + auto_upload: false + ) + + {:ok, socket} + end + + @impl true + def handle_params(params, _uri, socket) do + case resolve_dir_id(params["dir_id"]) do + {:ok, dir_id} -> + {:noreply, socket |> assign(current_dir_id: dir_id) |> refresh_listing()} + + {:error, :no_root} -> + {:noreply, + assign(socket, + current_dir_id: nil, + entries: %{dirs: [], files: []}, + trail: [], + needs_root?: true + )} + end + end + + # ── Events ──────────────────────────────────────────────────────────── + + @impl true + def handle_event("upload_validate", _params, socket), do: {:noreply, socket} + + def handle_event("upload_submit", _params, socket) do + dir_id = socket.assigns.current_dir_id + + consumed = + consume_uploaded_entries(socket, :files, fn %{path: path}, entry -> + case Dust.Daemon.FileSystem.upload(path, dir_id, entry.client_name) do + {:ok, file_uuid} -> {:ok, file_uuid} + {:error, reason} -> {:postpone, reason} + end + end) + + socket = + cond do + consumed == [] -> + socket + + Enum.all?(consumed, &is_binary/1) -> + socket |> put_flash(:info, "Uploaded #{length(consumed)} file(s).") |> refresh_listing() + + true -> + put_flash(socket, :error, "One or more uploads failed.") |> refresh_listing() + end + + {:noreply, socket} + end + + def handle_event("open_mkdir", _params, socket), + do: {:noreply, assign(socket, modal: :mkdir)} + + def handle_event("close_modal", _params, socket), + do: {:noreply, assign(socket, modal: nil, rename_target: nil)} + + def handle_event("mkdir_submit", %{"name" => name}, socket) do + case FS.mkdir(socket.assigns.current_dir_id, String.trim(name)) do + {:ok, _id} -> + {:noreply, + socket |> assign(modal: nil) |> put_flash(:info, "Created.") |> refresh_listing()} + + {:error, :already_exists} -> + {:noreply, put_flash(socket, :error, "A directory with that name already exists.")} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, "mkdir failed: #{inspect(reason)}")} + end + end + + def handle_event( + "open_rename", + %{"id" => id, "type" => type, "name" => name}, + socket + ) do + {:noreply, + assign(socket, modal: :rename, rename_target: %{id: id, type: type, current_name: name})} + end + + def handle_event("rename_submit", %{"name" => new_name}, socket) do + target = socket.assigns.rename_target + new_name = String.trim(new_name) + dir_id = socket.assigns.current_dir_id + + result = + case target.type do + "file" -> FS.move_file(target.id, dir_id, new_name) + "dir" -> FS.move_dir(target.id, dir_id, new_name) + end + + case result do + :ok -> + {:noreply, + socket + |> assign(modal: nil, rename_target: nil) + |> put_flash(:info, "Renamed.") + |> refresh_listing()} + + {:error, :name_conflict} -> + {:noreply, put_flash(socket, :error, "That name is already taken.")} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, "Rename failed: #{inspect(reason)}")} + end + end + + def handle_event("rm", %{"id" => id, "type" => "file"}, socket) do + case FS.rm_file(id) do + :ok -> {:noreply, refresh_listing(socket) |> put_flash(:info, "File deleted.")} + {:error, reason} -> {:noreply, put_flash(socket, :error, "Delete failed: #{inspect(reason)}")} + end + end + + def handle_event("rm", %{"id" => id, "type" => "dir"}, socket) do + case FS.rmdir(id) do + :ok -> + {:noreply, refresh_listing(socket) |> put_flash(:info, "Directory deleted.")} + + {:error, :not_empty} -> + {:noreply, put_flash(socket, :error, "Directory is not empty.")} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, "Delete failed: #{inspect(reason)}")} + end + end + + def handle_event("init_root", _params, socket) do + case FS.mkdir(nil, "root") do + {:ok, root_id} -> + _ = Dust.Utilities.Config.put(:root_dir_id, root_id) + + {:noreply, + push_patch(socket, to: ~p"/files/#{root_id}") |> put_flash(:info, "Root created.")} + + {:error, :root_already_exists} -> + # Another node already created root — find it and navigate there. + case existing_root() do + {:ok, id} -> {:noreply, push_patch(socket, to: ~p"/files/#{id}")} + _ -> {:noreply, put_flash(socket, :error, "Could not locate existing root.")} + end + + {:error, reason} -> + {:noreply, put_flash(socket, :error, "Init failed: #{inspect(reason)}")} + end + end + + # ── Progress messages from Dust.Daemon.FileSystem PubSub ────────────── + + @impl true + def handle_info({:upload_progress, file_uuid, chunk, total}, socket) do + {:noreply, update_progress(socket, file_uuid, :upload, chunk, total)} + end + + def handle_info({:download_progress, file_uuid, chunk, total}, socket) do + {:noreply, update_progress(socket, file_uuid, :download, chunk, total)} + end + + def handle_info(_msg, socket), do: {:noreply, socket} + + # ── Render ──────────────────────────────────────────────────────────── + + @impl true + def render(assigns) do + ~H""" +
+ <%= if assigns[:needs_root?] do %> +
+

No root directory yet

+

+ Initialize the filesystem to start uploading files. +

+ <.button phx-click="init_root" class="mt-4">Create root directory +
+ <% else %> +
+ <.breadcrumbs trail={@trail} /> + <.button phx-click="open_mkdir">New folder +
+ + <.upload_dropzone uploads={@uploads} /> + + <.file_table dirs={@entries.dirs} files={@entries.files} progress={@progress} /> + <% end %> + + <.modal :if={@modal == :mkdir} on_close="close_modal" title="New folder"> +
+ <.input name="name" id="mkdir-name" label="Name" required autocomplete="off" /> +
+ + <.button type="submit">Create +
+
+ + + <.modal :if={@modal == :rename} on_close="close_modal" title={"Rename #{@rename_target.type}"}> +
+ <.input + name="name" + id="rename-name" + label="New name" + value={@rename_target.current_name} + required + autocomplete="off" + /> +
+ + <.button type="submit">Rename +
+
+ +
+ """ + end + + # ── Helpers ─────────────────────────────────────────────────────────── + + defp resolve_dir_id(nil) do + case existing_root() do + {:ok, id} -> {:ok, id} + :error -> {:error, :no_root} + end + end + + defp resolve_dir_id(""), do: resolve_dir_id(nil) + defp resolve_dir_id(id) when is_binary(id), do: {:ok, id} + + defp existing_root do + case String.trim(Dust.Utilities.Config.root_dir_id()) do + "" -> + find_root_in_crdt() + + id -> + if FS.get_dir(id), do: {:ok, id}, else: find_root_in_crdt() + end + end + + defp find_root_in_crdt do + case Enum.find(FS.all_dirs(), fn {_id, dir} -> Map.get(dir, :parent_id) == nil end) do + {id, _dir} -> + # Best-effort cache; fine if it fails (e.g. already set, no permission). + _ = Dust.Utilities.Config.put(:root_dir_id, id) + {:ok, id} + + _ -> + :error + end + end + + defp refresh_listing(socket) do + dir_id = socket.assigns.current_dir_id + + case dir_id && FS.ls(dir_id) do + %{dirs: dirs, files: files} -> + assign(socket, + entries: %{dirs: dirs, files: files}, + trail: build_trail(dir_id), + needs_root?: false + ) + + _ -> + assign(socket, entries: %{dirs: [], files: []}, trail: build_trail(dir_id), needs_root?: false) + end + end + + defp build_trail(nil), do: [] + + defp build_trail(dir_id) do + walk_up(dir_id, []) + end + + defp walk_up(nil, acc), do: acc + + defp walk_up(id, acc) when is_binary(id) do + case FS.get_dir(id) do + nil -> + acc + + dir -> + crumb = %{id: id, name: Map.get(dir, :name) || "root"} + walk_up(Map.get(dir, :parent_id), [crumb | acc]) + end + end + + defp update_progress(socket, file_uuid, kind, chunk, total) do + entry = %{kind: kind, chunk: chunk, total: total} + + progress = + if chunk >= total do + Map.delete(socket.assigns.progress, file_uuid) + else + Map.put(socket.assigns.progress, file_uuid, entry) + end + + assign(socket, progress: progress) + end +end diff --git a/apps/dust_ui/lib/dust_ui/live/index_live.ex b/apps/dust_ui/lib/dust_ui/live/index_live.ex new file mode 100644 index 0000000..c939995 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/live/index_live.ex @@ -0,0 +1,100 @@ +defmodule Dust.Ui.IndexLive do + @moduledoc """ + Top-level dashboard. Three stat cards (files / peers / storage) plus a + system-readiness banner. + """ + use Dust.Ui, :live_view + + import Dust.Ui.StatCard + + alias Dust.Ui.Format + + @refresh_ms 5_000 + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + _ = Registry.register(Dust.Daemon.Registry, :system_ready, []) + _ = Dust.Mesh.NodeRegistry.subscribe() + Process.send_after(self(), :refresh, @refresh_ms) + end + + {:ok, assign(socket, page_title: "Dashboard") |> assign_metrics()} + end + + @impl true + def handle_info(:refresh, socket) do + Process.send_after(self(), :refresh, @refresh_ms) + {:noreply, assign_metrics(socket)} + end + + def handle_info({:system_ready, _node}, socket), do: {:noreply, assign_metrics(socket)} + + def handle_info({:node_registry_changes, _state}, socket), + do: {:noreply, assign_metrics(socket)} + + def handle_info(_msg, socket), do: {:noreply, socket} + + @impl true + def render(assigns) do + ~H""" +
+
+ Daemon is still bootstrapping. Some statistics may be unavailable. +
+ +

Overview

+ +
+ <.stat_card + title="Files" + value={Integer.to_string(@files_count)} + subtitle={"#{@dirs_count} directories"} + href={~p"/files"} + /> + <.stat_card + title="Peers online" + value={Integer.to_string(@online_peer_count)} + subtitle={"#{@total_peer_count} known"} + href={~p"/peers"} + /> + <.stat_card + title="Storage" + value={Format.bytes(@used_bytes)} + subtitle={"of #{Format.bytes(@quota_bytes)} quota"} + href={~p"/storage"} + /> +
+
+ """ + end + + defp assign_metrics(socket) do + system_ready? = Dust.Daemon.Readiness.ready?() + registry = safe(fn -> Dust.Mesh.NodeRegistry.list() end, %{}) + online = safe(fn -> Dust.Mesh.NodeRegistry.online_nodes() end, []) + files_count = safe(fn -> Dust.Mesh.FileSystem.all_files() |> map_size() end, 0) + dirs_count = safe(fn -> Dust.Mesh.FileSystem.all_dirs() |> map_size() end, 0) + quota_bytes = safe(fn -> Dust.Daemon.DiskManager.get_quota() end, 0) + used_bytes = safe(fn -> Dust.Daemon.DiskManager.usage_bytes() end, 0) + + assign(socket, + system_ready?: system_ready?, + files_count: files_count, + dirs_count: dirs_count, + online_peer_count: length(online), + total_peer_count: map_size(registry), + used_bytes: used_bytes, + quota_bytes: quota_bytes + ) + end + + defp safe(fun, fallback) do + try do + fun.() + catch + :exit, _ -> fallback + kind, _ when kind in [:error, :throw] -> fallback + end + end +end diff --git a/apps/dust_ui/lib/dust_ui/live/peers_live.ex b/apps/dust_ui/lib/dust_ui/live/peers_live.ex new file mode 100644 index 0000000..73526cf --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/live/peers_live.ex @@ -0,0 +1,111 @@ +defmodule Dust.Ui.PeersLive do + @moduledoc """ + Live dashboard of peer status and Tailscale bridge state. + """ + use Dust.Ui, :live_view + + import Dust.Ui.PeerCard + + @refresh_ms 10_000 + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + _ = Dust.Mesh.NodeRegistry.subscribe() + Process.send_after(self(), :refresh, @refresh_ms) + end + + {:ok, assign(socket, page_title: "Peers") |> assign_state()} + end + + @impl true + def handle_info(:refresh, socket) do + Process.send_after(self(), :refresh, @refresh_ms) + {:noreply, assign_state(socket)} + end + + def handle_info({:node_registry_changes, _state}, socket), + do: {:noreply, assign_state(socket)} + + def handle_info(_msg, socket), do: {:noreply, socket} + + @impl true + def render(assigns) do + ~H""" +
+
+

Peers

+

Cluster membership and Tailscale bridge state.

+
+ +
+

Tailscale

+

+ State: {@bridge.state} +

+

+ IP: + {@bridge.self_ip} +

+

+ + Open auth URL + +

+

+ Bridge error: {@bridge.error} +

+
+ +
+ No peers known yet. +
+ +
+ <.peer_card + :for={p <- @peers} + node={p.node} + status={p.status} + seen_at={p.seen_at} + fitness={p.fitness} + /> +
+
+ """ + end + + defp assign_state(socket) do + bridge = + case safe(fn -> Dust.Bridge.auth_status() end, :error) do + {:ok, info} -> Map.merge(%{state: "unknown", self_ip: nil, auth_url: nil, error: nil}, info) + {:error, reason} -> %{state: "error", self_ip: nil, auth_url: nil, error: inspect(reason)} + :error -> %{state: "unavailable", self_ip: nil, auth_url: nil, error: nil} + end + + registry = safe(fn -> Dust.Mesh.NodeRegistry.list() end, %{}) + fitness_by_node = safe(fn -> Dust.Core.Fitness.list() end, []) |> Map.new() + + peers = + registry + |> Enum.map(fn {node, info} -> + %{ + node: node, + status: Map.get(info, :status, :unknown), + seen_at: Map.get(info, :seen_at), + fitness: Map.get(fitness_by_node, node) + } + end) + |> Enum.sort_by(fn p -> {p.status != :online, to_string(p.node)} end) + + assign(socket, bridge: bridge, peers: peers) + end + + defp safe(fun, fallback) do + try do + fun.() + catch + :exit, _ -> fallback + kind, _ when kind in [:error, :throw] -> fallback + end + end +end diff --git a/apps/dust_ui/lib/dust_ui/live/storage_live.ex b/apps/dust_ui/lib/dust_ui/live/storage_live.ex new file mode 100644 index 0000000..eb979a0 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/live/storage_live.ex @@ -0,0 +1,149 @@ +defmodule Dust.Ui.StorageLive do + @moduledoc """ + Storage health dashboard. Disk usage vs quota, GC and Repair scheduler + stats, and a replication-health count. + """ + use Dust.Ui, :live_view + + import Dust.Ui.StatCard + + alias Dust.Ui.Format + + @refresh_ms 10_000 + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + Process.send_after(self(), :refresh, @refresh_ms) + end + + {:ok, assign(socket, page_title: "Storage") |> assign_state()} + end + + @impl true + def handle_info(:refresh, socket) do + Process.send_after(self(), :refresh, @refresh_ms) + {:noreply, assign_state(socket)} + end + + def handle_info(_msg, socket), do: {:noreply, socket} + + @impl true + def render(assigns) do + ~H""" +
+
+

Storage

+

Local shard usage and background-sweep health.

+
+ +
+
+
+

Local usage

+

{Format.bytes(@used_bytes)}

+
+

of {Format.bytes(@quota_bytes)}

+
+
+
+
+

+ {@shard_count} shards · replication factor {@replication_factor} +

+
+ +
+ <.stat_card + title="Garbage collector" + value={Integer.to_string(@gc.last_orphans_removed + @gc.last_replicas_removed)} + subtitle={"Last sweep #{Format.relative_time(@gc.last_sweep_at)} · #{@gc.last_orphans_removed} orphans, #{@gc.last_replicas_removed} replicas"} + /> + <.stat_card + title="Repair scheduler" + value={Integer.to_string(@repair.shards_cloned + @repair.shards_reconstructed)} + subtitle={"Last sweep #{Format.relative_time(@repair.last_sweep_at)} · #{@repair.shards_cloned} cloned, #{@repair.shards_reconstructed} reconstructed"} + /> + <.stat_card + title="Integrity issues (last sweep)" + value={Integer.to_string(@repair.integrity_removed)} + subtitle={"Stale manifest entries: #{@repair.stale_entries_cleaned}"} + /> + <.stat_card + title="Under-replicated chunks" + value={Integer.to_string(@under_replicated)} + subtitle="Online holders below replication factor" + /> +
+
+ """ + end + + defp assign_state(socket) do + quota_bytes = safe(fn -> Dust.Daemon.DiskManager.get_quota() end, 0) + used_bytes = safe(fn -> Dust.Daemon.DiskManager.usage_bytes() end, 0) + replication_factor = safe(fn -> Dust.Utilities.Config.replication_factor() end, 1) + online = safe(fn -> Dust.Mesh.NodeRegistry.online_nodes() end, []) |> MapSet.new() + gc = safe(fn -> Dust.Daemon.GarbageCollector.stats() end, default_gc()) + repair = safe(fn -> Dust.Daemon.RepairScheduler.stats() end, default_repair()) + + {shard_count, under_replicated} = + safe( + fn -> shard_stats(Dust.Mesh.Manifest.ShardMap.all_grouped(), online, replication_factor) end, + {0, 0} + ) + + assign(socket, + quota_bytes: quota_bytes, + used_bytes: used_bytes, + replication_factor: replication_factor, + shard_count: shard_count, + under_replicated: under_replicated, + gc: Map.merge(default_gc(), gc), + repair: Map.merge(default_repair(), repair) + ) + end + + defp shard_stats(grouped, online, replication_factor) when is_map(grouped) do + Enum.reduce(grouped, {0, 0}, fn {_chunk_hash, shards}, {count, under} -> + online_holders = + shards + |> Enum.flat_map(fn {_idx, %{nodes: nodes}} -> MapSet.to_list(nodes) end) + |> MapSet.new() + |> MapSet.intersection(online) + |> MapSet.size() + + delta_under = if online_holders < replication_factor, do: 1, else: 0 + {count + 1, under + delta_under} + end) + end + + defp shard_stats(_, _, _), do: {0, 0} + + defp quota_percent(_used, 0), do: 0 + + defp quota_percent(used, quota) when is_integer(quota) and quota > 0, + do: trunc(used * 100 / quota) |> min(100) |> max(0) + + defp quota_percent(_, _), do: 0 + + defp default_gc, do: %{last_sweep_at: nil, last_orphans_removed: 0, last_replicas_removed: 0} + + defp default_repair, + do: %{ + last_sweep_at: nil, + integrity_removed: 0, + shards_cloned: 0, + shards_reconstructed: 0, + stale_entries_cleaned: 0 + } + + defp safe(fun, fallback) do + try do + fun.() + catch + :exit, _ -> fallback + kind, _ when kind in [:error, :throw] -> fallback + end + end +end diff --git a/apps/dust_ui/lib/dust_ui/router.ex b/apps/dust_ui/lib/dust_ui/router.ex new file mode 100644 index 0000000..d7cf991 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/router.ex @@ -0,0 +1,46 @@ +defmodule Dust.Ui.Router do + use Dust.Ui, :router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {Dust.Ui.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :require_auth do + plug Dust.Ui.Auth.Session, :require_authenticated + end + + pipeline :redirect_if_authed do + plug Dust.Ui.Auth.Session, :redirect_if_authenticated + end + + scope "/", Dust.Ui do + pipe_through :browser + + scope "/" do + pipe_through :redirect_if_authed + get "/login", SessionController, :new + post "/login", SessionController, :create + end + + delete "/logout", SessionController, :delete + + scope "/" do + pipe_through :require_auth + + live_session :authenticated, on_mount: [Dust.Ui.Auth.LiveAuth] do + live "/", IndexLive, :show + live "/files", FilesLive, :root + live "/files/:dir_id", FilesLive, :show + live "/peers", PeersLive, :show + live "/storage", StorageLive, :show + end + + get "/download/:file_id", DownloadController, :show + end + end +end diff --git a/apps/dust_ui/lib/dust_ui/telemetry.ex b/apps/dust_ui/lib/dust_ui/telemetry.ex new file mode 100644 index 0000000..e2b1a82 --- /dev/null +++ b/apps/dust_ui/lib/dust_ui/telemetry.ex @@ -0,0 +1,50 @@ +defmodule Dust.Ui.Telemetry do + @moduledoc false + + 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, measurements: periodic_measurements(), period: 10_000} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + 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}), + summary("phoenix.channel_joined.duration", unit: {:native, :millisecond}), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), + 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: [] +end diff --git a/apps/dust_ui/mix.exs b/apps/dust_ui/mix.exs index c6d1dad..e6a58cf 100644 --- a/apps/dust_ui/mix.exs +++ b/apps/dust_ui/mix.exs @@ -11,23 +11,50 @@ defmodule Dust.Ui.MixProject do lockfile: "../../mix.lock", elixir: "~> 1.19", start_permanent: Mix.env() == :prod, + aliases: aliases(), deps: deps() ] end - # Run "mix help compile.app" to learn about applications. def application do [ - extra_applications: [:logger] + extra_applications: [:logger, :runtime_tools], + mod: {Dust.Ui.Application, []} ] end - # Run "mix help deps" to learn about dependencies. defp deps do [ - # {:dep_from_hexpm, "~> 0.3.0"}, - # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}, - # {:sibling_app_in_umbrella, in_umbrella: true} + {:phoenix, "~> 1.8"}, + {:phoenix_live_view, "~> 1.0"}, + {:phoenix_live_reload, "~> 1.5", only: :dev}, + {:phoenix_html, "~> 4.1"}, + {:phoenix_pubsub, "~> 2.1"}, + {:bandit, "~> 1.6"}, + {:plug, "~> 1.16"}, + {:jason, "~> 1.4"}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.1"}, + {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, + {:floki, ">= 0.30.0", only: :test}, + {:dust_daemon, in_umbrella: true}, + {:dust_core, in_umbrella: true}, + {:dust_mesh, in_umbrella: true}, + {:dust_bridge, in_umbrella: true}, + {:dust_utilities, in_umbrella: true} + ] + end + + defp aliases do + [ + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["tailwind dust_ui", "esbuild dust_ui"], + "assets.deploy": [ + "tailwind dust_ui --minify", + "esbuild dust_ui --minify", + "phx.digest" + ] ] end end diff --git a/apps/dust_utilities/lib/dust_utilities/config.ex b/apps/dust_utilities/lib/dust_utilities/config.ex index 87153f0..bac7a81 100644 --- a/apps/dust_utilities/lib/dust_utilities/config.ex +++ b/apps/dust_utilities/lib/dust_utilities/config.ex @@ -41,6 +41,8 @@ defmodule Dust.Utilities.Config do max_reconstruct_per_sweep: 5, api_port: 4884, api_bind: "127.0.0.1", + ui_port: 4885, + ui_bind: "127.0.0.1", root_dir_id: "", node_name: "dust" } @@ -53,6 +55,8 @@ defmodule Dust.Utilities.Config do :max_reconstruct_per_sweep, :api_port, :api_bind, + :ui_port, + :ui_bind, :root_dir_id, :node_name ] @@ -88,6 +92,12 @@ defmodule Dust.Utilities.Config do # api_bind — IP address the HTTP API binds to. # Use "127.0.0.1" (default) to restrict to localhost. # + # ui_port — TCP port for the local web UI (Phoenix LiveView). + # Default: 4885. + # + # ui_bind — IP address the web UI binds to. + # Use "127.0.0.1" (default) to restrict to localhost. + # # root_dir_id — UUID of the root filesystem directory. # Set automatically during setup or can be empty. # @@ -143,6 +153,14 @@ defmodule Dust.Utilities.Config do @spec api_bind() :: String.t() def api_bind, do: get(:api_bind) + @doc "TCP port for the local web UI." + @spec ui_port() :: pos_integer() + def ui_port, do: get(:ui_port) + + @doc "IP address the web UI binds to." + @spec ui_bind() :: String.t() + def ui_bind, do: get(:ui_bind) + @doc "UUID of the root directory." @spec root_dir_id() :: String.t() def root_dir_id, do: get(:root_dir_id) @@ -409,6 +427,8 @@ defmodule Dust.Utilities.Config do defp validate_key(:persist_dir, v) when is_binary(v) and v != "", do: :ok defp validate_key(:api_port, v) when is_integer(v) and v > 0 and v <= 65535, do: :ok defp validate_key(:api_bind, v) when is_binary(v) and v != "", do: :ok + defp validate_key(:ui_port, v) when is_integer(v) and v > 0 and v <= 65535, do: :ok + defp validate_key(:ui_bind, v) when is_binary(v) and v != "", do: :ok defp validate_key(:root_dir_id, v) when is_binary(v), do: :ok defp validate_key(:node_name, v) when is_binary(v) do diff --git a/config/config.exs b/config/config.exs index 22b2f23..c469605 100644 --- a/config/config.exs +++ b/config/config.exs @@ -9,14 +9,38 @@ # move said applications out of the umbrella. import Config -# Sample configuration: -# -# config :logger, :default_handler, -# level: :info -# -# config :logger, :default_formatter, -# format: "$date $time [$level] $metadata$message\n", -# metadata: [:user_id] -# +# ── Web UI (Phoenix LiveView) ────────────────────────────────────────── +config :dust_ui, Dust.Ui.Endpoint, + adapter: Bandit.PhoenixAdapter, + url: [host: "localhost"], + http: [ip: {127, 0, 0, 1}, port: 4885], + render_errors: [ + formats: [html: Dust.Ui.ErrorHTML, json: Dust.Ui.ErrorJSON], + layout: false + ], + pubsub_server: Dust.Ui.PubSub, + live_view: [signing_salt: "dust_ui_live"] + +config :phoenix, :json_library, Jason + +config :esbuild, + version: "0.21.5", + dust_ui: [ + args: + ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), + cd: Path.expand("../apps/dust_ui/assets", __DIR__), + env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + ] + +config :tailwind, + version: "3.4.3", + dust_ui: [ + args: ~w( + --config=tailwind.config.js + --input=css/app.css + --output=../priv/static/assets/app.css + ), + cd: Path.expand("../apps/dust_ui/assets", __DIR__) + ] import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs index e5f2c1b..d5aaacf 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -4,3 +4,25 @@ import Config config :dust_utilities, :config, %{ persist_dir: Path.join(File.cwd!(), "dust_dev") } + +# ── Web UI dev settings ──────────────────────────────────────────────── +config :dust_ui, Dust.Ui.Endpoint, + # Generated/overwritten at runtime; this placeholder lets the endpoint compile. + secret_key_base: "dev_only_secret_key_base_placeholder_must_be_at_least_64_chars_xxx", + debug_errors: true, + code_reloader: true, + check_origin: false, + watchers: [ + esbuild: {Esbuild, :install_and_run, [:dust_ui, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [:dust_ui, ~w(--watch)]} + ], + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", + ~r"lib/dust_ui/(controllers|live|components)/.*(ex|heex)$" + ] + ] + +config :phoenix, :stacktrace_depth, 20 +config :phoenix, :plug_init_mode, :runtime +config :phoenix_live_view, :debug_heex_annotations, true diff --git a/config/prod.exs b/config/prod.exs index 734d9be..38a59e2 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -2,3 +2,10 @@ import Config # Dust persist file root directory config :dust_utilities, :persist_dir, Path.join(System.user_home!(), ".dust") + +# ── Web UI prod settings ─────────────────────────────────────────────── +# secret_key_base is generated and persisted on first boot by +# Dust.Ui.Application — see /ui_secret_key_base. +config :dust_ui, Dust.Ui.Endpoint, + cache_static_manifest: "priv/static/cache_manifest.json", + server: true diff --git a/config/runtime.exs b/config/runtime.exs index 2ab2e00..f2367ca 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -10,13 +10,21 @@ api_port = String.to_integer(port_str) end +ui_port = + if port_str = System.get_env("DUST_UI_PORT") do + String.to_integer(port_str) + end + api_bind = System.get_env("DUST_API_BIND") +ui_bind = System.get_env("DUST_UI_BIND") persist_dir = System.get_env("DUST_DATA_DIR") runtime_overrides = %{} |> then(fn m -> if api_port, do: Map.put(m, :api_port, api_port), else: m end) |> then(fn m -> if api_bind, do: Map.put(m, :api_bind, api_bind), else: m end) + |> then(fn m -> if ui_port, do: Map.put(m, :ui_port, ui_port), else: m end) + |> then(fn m -> if ui_bind, do: Map.put(m, :ui_bind, ui_bind), else: m end) |> then(fn m -> if persist_dir, do: Map.put(m, :persist_dir, persist_dir), else: m end) if map_size(runtime_overrides) > 0 do diff --git a/config/test.exs b/config/test.exs index ee7167a..ce31b3d 100644 --- a/config/test.exs +++ b/config/test.exs @@ -15,5 +15,12 @@ config :dust_bridge, :start_sidecar, false # in tests so it never collides with a running dust daemon on the dev box. config :dust_utilities, :config, %{ persist_dir: Path.join(System.tmp_dir!(), "dust_test"), - api_port: 14884 + api_port: 14884, + ui_port: 14885 } + +# ── Web UI test settings ─────────────────────────────────────────────── +config :dust_ui, Dust.Ui.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 14885], + secret_key_base: "test_secret_key_base_placeholder_must_be_at_least_64_chars_long_xx", + server: false diff --git a/mix.exs b/mix.exs index adbc185..dc5aa3e 100644 --- a/mix.exs +++ b/mix.exs @@ -31,7 +31,8 @@ defmodule Dust.MixProject do dust_storage: :permanent, dust_mesh: :permanent, dust_daemon: :permanent, - dust_api: :permanent + dust_api: :permanent, + dust_ui: :permanent ], strip_beams: [keep: ["Docs"]], cookie: "dust_cookie" diff --git a/mix.lock b/mix.lock index acbe6eb..012a605 100644 --- a/mix.lock +++ b/mix.lock @@ -9,8 +9,11 @@ "disk_space": {:hex, :disk_space, "1.0.0", "711506d24145697c205db5e7d9ef5fdf96fc575c9f0fb8b9133a4bf272876c65", [:mix], [{:rustler, "~> 0.36.2", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "5b746903cdad904ee1790140a960b0ad8957e789b0dc83c496e2cbda73bf4595"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, + "floki": {:hex, :floki, "0.38.3", "40d291831d93f49aa360f09447cf2e2a902e33d8711e5fb22a75f3f333e9d063", [:mix], [], "hexpm", "025aa1f5f24a70cb31bfbc7011419228596f3b062d7feda617238ba4926f83cb"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, @@ -25,6 +28,12 @@ "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, + "phoenix": {:hex, :phoenix, "1.8.7", "d8d755b4ff4b449f610223dd706b4ae64155cb720d3dc09c706c079ecea189e4", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "47352f72d6ab31009ef77516b1b3a14745be97b54061fd458031b9d8294869d5"}, + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.30", "a84af1610755dc208da35d4d45564485edbf18c3f3c77373c4a650dc994cdcdb", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a353c51ac1e3190910f01a6100c7d5cc02c5e22e7374fd817bd3aedd21149039"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "reed_solomon_ex": {:hex, :reed_solomon_ex, "0.1.3", "beb4d9b892a3a13e5161233dffa9a105aafee078d6e273cd3aa566d04a606a58", [:mix], [{:rustler, "~> 0.37", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "7823ed5ba7f3afc1a25e0b7a7babe63b6c6f6b15cbd49d59b16265f2055dd0d5"}, @@ -34,11 +43,14 @@ "rs_simd": {:hex, :reed_solomon_simd, "0.1.0", "d4fe71e4239a19540285402c65bcbc9f77163ae50caaa3ff3725735dd23c747d", [:mix], [{:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "1243b4189b07494306963145daa3bde5325c9139ef62842b797021d44a54083a"}, "rustler": {:hex, :rustler, "0.37.3", "5f4e6634d43b26f0a69834dd1d3ed4e1710b022a053bf4a670220c9540c92602", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a6872c6f53dcf00486d1e7f9e046e20e01bf1654bdacc4193016c2e8002b32a2"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, + "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, - "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, "websockex": {:hex, :websockex, "0.4.3", "92b7905769c79c6480c02daacaca2ddd49de936d912976a4d3c923723b647bf0", [:mix], [], "hexpm", "95f2e7072b85a3a4cc385602d42115b73ce0b74a9121d0d6dbbf557645ac53e4"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"}, From 06845f2fb74c4d6e7b5aff3f35e216f32eb24cc0 Mon Sep 17 00:00:00 2001 From: Andrew Boessen Date: Thu, 28 May 2026 11:28:36 -0500 Subject: [PATCH 02/11] fix file upload on ui --- .../lib/dust_ui/components/file_table.ex | 4 +- apps/dust_ui/priv/static/assets/app.css | 1296 +++ apps/dust_ui/priv/static/assets/app.js | 8655 +++++++++++++++++ flake.nix | 3 + mix.exs | 1 + 5 files changed, 9957 insertions(+), 2 deletions(-) create mode 100644 apps/dust_ui/priv/static/assets/app.css create mode 100644 apps/dust_ui/priv/static/assets/app.js diff --git a/apps/dust_ui/lib/dust_ui/components/file_table.ex b/apps/dust_ui/lib/dust_ui/components/file_table.ex index d729b76..d9b20f6 100644 --- a/apps/dust_ui/lib/dust_ui/components/file_table.ex +++ b/apps/dust_ui/lib/dust_ui/components/file_table.ex @@ -76,7 +76,7 @@ defmodule Dust.Ui.FileTable do — - {Format.relative_time(dir[:created_at])} + {Format.relative_time(Map.get(dir, :created_at))}