From 94034eea9b6c415c754a3b5220549dfe29889b4a Mon Sep 17 00:00:00 2001 From: Carsten Date: Tue, 21 Apr 2026 22:54:47 +0200 Subject: [PATCH] feat(server): vm search LiveView with name+IP filtering --- server/lib/server_web/live/vm_search_live.ex | 104 ++++++++++++++++++ .../server_web/live/vm_search_live_test.exs | 71 ++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 server/lib/server_web/live/vm_search_live.ex create mode 100644 server/test/server_web/live/vm_search_live_test.exs diff --git a/server/lib/server_web/live/vm_search_live.ex b/server/lib/server_web/live/vm_search_live.ex new file mode 100644 index 0000000..5c9f51c --- /dev/null +++ b/server/lib/server_web/live/vm_search_live.ex @@ -0,0 +1,104 @@ +defmodule ServerWeb.VmSearchLive do + use ServerWeb, :live_view + + alias Server.{Hosts, Metrics} + + @impl true + def mount(_params, _session, socket) do + if connected?(socket), do: Phoenix.PubSub.subscribe(Server.PubSub, "metrics") + {:ok, socket |> assign(:q, "") |> assign(:vms, load_vms())} + end + + @impl true + def handle_info({:metric_inserted, _, _}, socket) do + {:noreply, assign(socket, :vms, load_vms())} + end + + @impl true + def handle_event("search", %{"q" => q}, socket) do + {:noreply, assign(socket, :q, q)} + end + + defp load_vms do + for host <- Hosts.list_all(), + runtime_sample = Metrics.latest_sample(host.id, "fast"), + vm <- get_in(runtime_sample && runtime_sample.payload, ["vms_runtime", "vms"]) || [], + into: [] do + detail_sample = Metrics.latest_sample(host.id, "medium") + detail_vms = get_in(detail_sample && detail_sample.payload, ["vms_detail", "vms"]) || [] + detail_vm = Enum.find(detail_vms, &(&1["vmid"] == vm["vmid"])) || %{} + ips = detail_vm["ips"] || [] + + %{ + vmid: vm["vmid"], + name: vm["name"], + type: vm["type"], + status: vm["status"], + host_name: host.name, + ips: ips + } + end + end + + defp filter(vms, ""), do: vms + + defp filter(vms, q) do + q = String.downcase(q) + + Enum.filter(vms, fn vm -> + String.contains?(String.downcase(vm.name || ""), q) or + Enum.any?(vm.ips, &String.contains?(&1, q)) + end) + end + + @impl true + def render(assigns) do + ~H""" +
+ <.link navigate={~p"/"} class="text-sm text-zinc-500 hover:text-zinc-900">← Back +

VM Search

+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
NameHostTypeStatusIPs
{vm.name} + <.link + navigate={~p"/hosts/#{vm.host_name}"} + class="text-zinc-700 hover:text-zinc-900 underline" + > + {vm.host_name} + + {vm.type}{vm.status}{Enum.join(vm.ips, ", ")}
No matches.
+
+ """ + end +end diff --git a/server/test/server_web/live/vm_search_live_test.exs b/server/test/server_web/live/vm_search_live_test.exs new file mode 100644 index 0000000..1596502 --- /dev/null +++ b/server/test/server_web/live/vm_search_live_test.exs @@ -0,0 +1,71 @@ +defmodule ServerWeb.VmSearchLiveTest do + use ServerWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + alias Server.{Hosts, Metrics} + + defp auth(conn), do: Plug.Test.init_test_session(conn, %{authenticated: true}) + + setup do + {:ok, {h1, _}} = Hosts.create_host("pve-01") + {:ok, {h2, _}} = Hosts.create_host("pve-02") + + fast1 = %{ + "vms_runtime" => %{ + "vms" => [ + %{"vmid" => 100, "name" => "nginx-proxy", "type" => "qemu", "status" => "running"} + ] + } + } + + fast2 = %{ + "vms_runtime" => %{ + "vms" => [ + %{"vmid" => 200, "name" => "db-primary", "type" => "qemu", "status" => "running"} + ] + } + } + + medium1 = %{ + "vms_detail" => %{ + "vms" => [%{"vmid" => 100, "name" => "nginx-proxy", "ips" => ["192.168.1.10"]}] + } + } + + {:ok, _} = Metrics.record_sample(h1.id, "fast", DateTime.utc_now(), fast1) + {:ok, _} = Metrics.record_sample(h2.id, "fast", DateTime.utc_now(), fast2) + {:ok, _} = Metrics.record_sample(h1.id, "medium", DateTime.utc_now(), medium1) + + :ok + end + + test "lists all VMs from all hosts by default", %{conn: conn} do + {:ok, _view, html} = live(auth(conn), "/vms") + assert html =~ "nginx-proxy" + assert html =~ "db-primary" + end + + test "filters by name substring", %{conn: conn} do + {:ok, view, _html} = live(auth(conn), "/vms") + + html = + view + |> form("form", q: "nginx") + |> render_change() + + assert html =~ "nginx-proxy" + refute html =~ "db-primary" + end + + test "filters by IP substring (matches detail payload)", %{conn: conn} do + {:ok, view, _html} = live(auth(conn), "/vms") + + html = + view + |> form("form", q: "192.168.1") + |> render_change() + + assert html =~ "nginx-proxy" + refute html =~ "db-primary" + end +end