New design language:
- dark background, system sans for UI, monospace for data
- single green accent, amber/red for warn/critical
- square-bordered panels + tables, no rounded cards or shadows
- status conveyed via left-border on overview cards + badges
Changes:
- new app.css defines CSS vars + component classes (.panel, .tbl,
.card, .btn, .input, .badge with [data-status=*])
- new ServerWeb.DashboardNav function component for a shared top nav
with active-link highlighting; replaces per-view navigation clutter
- strip the Phoenix welcome scaffold (logo, version badge, twitter/GH
links) from layouts/app.html.heex; leaves only flash + content
- root.html.heex title suffix switched to 'Proxmox Monitor', body
loses the Tailwind-white background
- rewrite render/1 in all four LiveViews + login template to use the
new classes; admin form now uses <.form for={@form}> and properly
clears on success
- login page redesigned to a single tight panel matching the rest
All 58 tests still pass; 'mix compile --warnings-as-errors' is clean.
110 lines
3.1 KiB
Elixir
110 lines
3.1 KiB
Elixir
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: "", vms: load_vms(), page_title: "VM Search")}
|
|
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"""
|
|
<ServerWeb.DashboardNav.nav active={:vms} />
|
|
|
|
<div class="page">
|
|
<div class="pagehead">
|
|
<h1>VM Search</h1>
|
|
<span class="sub">{length(filter(@vms, @q))} / {length(@vms)} shown</span>
|
|
</div>
|
|
|
|
<form phx-change="search">
|
|
<input
|
|
name="q"
|
|
value={@q}
|
|
placeholder="name or ip…"
|
|
autocomplete="off"
|
|
autofocus
|
|
class="input"
|
|
/>
|
|
</form>
|
|
|
|
<div class="panel">
|
|
<div class="body tight">
|
|
<table class="tbl" :if={filter(@vms, @q) != []}>
|
|
<thead>
|
|
<tr>
|
|
<th>VMID</th>
|
|
<th>Name</th>
|
|
<th>Host</th>
|
|
<th>Type</th>
|
|
<th>Status</th>
|
|
<th>IPs</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={vm <- filter(@vms, @q)}>
|
|
<td class="num">{vm.vmid}</td>
|
|
<td class="mono">{vm.name}</td>
|
|
<td><.link navigate={~p"/hosts/#{vm.host_name}"}>{vm.host_name}</.link></td>
|
|
<td>{vm.type}</td>
|
|
<td><span class="badge" data-status={vm_status(vm)}>{vm.status}</span></td>
|
|
<td class="mono" style="font-size: 0.75rem;">{Enum.join(vm.ips, ", ")}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<div :if={filter(@vms, @q) == []} class="empty">No matches.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp vm_status(%{status: "running"}), do: "ok"
|
|
defp vm_status(%{status: "stopped"}), do: "offline"
|
|
defp vm_status(_), do: "warning"
|
|
end
|