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
+
+
+
+
+
+
+ | Name |
+ Host |
+ Type |
+ Status |
+ IPs |
+
+
+
+
+ | {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