diff --git a/server/assets/css/app.css b/server/assets/css/app.css index 378c8f9..22c64cb 100644 --- a/server/assets/css/app.css +++ b/server/assets/css/app.css @@ -2,4 +2,311 @@ @import "tailwindcss/components"; @import "tailwindcss/utilities"; -/* This file is for your main application CSS */ +/* -------------------------------------------------------------------------- * + * Minimal utilitarian dashboard. + * Dark background, system sans for UI, monospace for data. + * Status conveyed via borders and small badges, no gradients or shadows. + * -------------------------------------------------------------------------- */ + +:root { + --bg: #0f1115; + --panel: #171a21; + --panel-2: #1d2029; + --border: #2a2f3a; + --border-strong: #3a414f; + --fg: #e6e9ef; + --fg-bright: #ffffff; + --muted: #8a93a4; + --dim: #6b7280; + + --ok: #7dd3a0; + --warn: #e8b764; + --crit: #e56b6b; + --offline: #6b7280; + + --link: #8fb4d9; + --accent: var(--ok); + + --mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, sans-serif; +} + +html, body { + background: var(--bg); + color: var(--fg); + font-family: var(--sans); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + margin: 0; +} + +a { color: var(--link); text-decoration: none; } +a:hover { text-decoration: underline; } +code, pre, .mono { font-family: var(--mono); } + +h1, h2, h3 { font-weight: 600; color: var(--fg-bright); margin: 0; } +h1 { font-size: 1.25rem; letter-spacing: -0.01em; } +h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted); font-weight: 600; } +h3 { font-size: 0.95rem; } + +hr { border: 0; border-top: 1px solid var(--border); margin: 0; } + +/* --- nav bar -------------------------------------------------------------- */ +.nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.7rem 1.2rem; + border-bottom: 1px solid var(--border); + background: var(--bg); + font-size: 0.9rem; +} +.nav .brand { + color: var(--fg-bright); + font-weight: 600; + letter-spacing: 0.03em; +} +.nav .links { display: flex; gap: 1.1rem; align-items: center; } +.nav .links a { color: var(--muted); } +.nav .links a.active, .nav .links a:hover { color: var(--fg-bright); text-decoration: none; } +.nav .spacer { flex: 1; } +.nav .signout { color: var(--muted); font-size: 0.85rem; } + +/* --- page container ------------------------------------------------------- */ +.page { + max-width: 1200px; + margin: 0 auto; + padding: 1.2rem; + display: flex; + flex-direction: column; + gap: 1.2rem; +} +.page .pagehead { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; +} +.page .pagehead .sub { + color: var(--muted); + font-size: 0.85rem; + font-family: var(--mono); +} + +/* --- panel ---------------------------------------------------------------- */ +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 2px; +} +.panel > header { + padding: 0.55rem 0.9rem; + border-bottom: 1px solid var(--border); + color: var(--muted); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.1em; + display: flex; + justify-content: space-between; + align-items: center; +} +.panel > .body { + padding: 0.9rem; +} +.panel > .body.tight { padding: 0; } + +/* --- table ---------------------------------------------------------------- */ +.tbl { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} +.tbl th, .tbl td { + text-align: left; + padding: 0.45rem 0.9rem; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} +.tbl th { + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + font-size: 0.72rem; + letter-spacing: 0.08em; + background: var(--panel-2); +} +.tbl td .mono, .tbl td.mono { font-family: var(--mono); color: var(--fg-bright); } +.tbl tr:last-child td { border-bottom: none; } +.tbl tr:hover td { background: var(--panel-2); } +.tbl td.right { text-align: right; } +.tbl td.num { font-variant-numeric: tabular-nums; font-family: var(--mono); } + +/* --- buttons / inputs ----------------------------------------------------- */ +.btn { + display: inline-flex; + align-items: center; + padding: 0.35rem 0.9rem; + border: 1px solid var(--border-strong); + background: var(--panel-2); + color: var(--fg); + font-family: var(--sans); + font-size: 0.85rem; + cursor: pointer; + border-radius: 2px; + line-height: 1.2; +} +.btn:hover { background: var(--border); color: var(--fg-bright); } +.btn.primary { border-color: var(--ok); color: var(--ok); } +.btn.primary:hover { background: rgba(125, 211, 160, 0.12); color: var(--ok); } +.btn.danger { border-color: var(--crit); color: var(--crit); } +.btn.danger:hover { background: rgba(229, 107, 107, 0.1); color: var(--crit); } +.btn.ghost { border-color: transparent; background: transparent; color: var(--muted); } +.btn.ghost:hover { color: var(--fg); background: var(--panel-2); } +.btn.sm { padding: 0.15rem 0.5rem; font-size: 0.75rem; } + +.input { + display: block; + width: 100%; + padding: 0.4rem 0.7rem; + background: var(--bg); + border: 1px solid var(--border-strong); + color: var(--fg); + font-family: var(--mono); + font-size: 0.9rem; + border-radius: 2px; + outline: none; +} +.input:focus { border-color: var(--link); } +.input::placeholder { color: var(--dim); } + +/* --- form rows ------------------------------------------------------------ */ +.form-row { + display: flex; + gap: 0.5rem; + align-items: stretch; +} +.form-row .input { flex: 1; } + +/* --- status badges / ampel ----------------------------------------------- */ +.badge { + display: inline-block; + font-family: var(--mono); + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 0.1rem 0.45rem; + border-radius: 2px; + border: 1px solid currentColor; +} +.badge[data-status="ok"], .status-ok { color: var(--ok); } +.badge[data-status="warning"], .status-warn { color: var(--warn); } +.badge[data-status="critical"], .status-crit { color: var(--crit); } +.badge[data-status="offline"], .badge[data-status="never_connected"], .status-offline { color: var(--offline); } +.badge[data-status="online"] { color: var(--ok); } + +/* --- host cards (overview grid) ----------------------------------------- */ +.cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 0.9rem; +} +.card { + background: var(--panel); + border: 1px solid var(--border); + border-left: 3px solid var(--offline); + border-radius: 2px; + padding: 0.9rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + cursor: pointer; + text-decoration: none; + color: inherit; +} +.card:hover { background: var(--panel-2); text-decoration: none; } +.card[data-status="ok"] { border-left-color: var(--ok); } +.card[data-status="warning"] { border-left-color: var(--warn); } +.card[data-status="critical"] { border-left-color: var(--crit); } +.card[data-status="offline"] { border-left-color: var(--offline); opacity: 0.7; } +.card .name { font-family: var(--mono); font-weight: 600; color: var(--fg-bright); font-size: 1rem; } +.card .seen { color: var(--muted); font-size: 0.75rem; } +.card .stat { display: flex; justify-content: space-between; font-size: 0.8rem; } +.card .stat .k { color: var(--muted); } +.card .stat .v { font-family: var(--mono); color: var(--fg); } + +/* --- detail rows --------------------------------------------------------- */ +.kv { + display: grid; + grid-template-columns: 140px 1fr; + gap: 0.5rem 1rem; + font-size: 0.85rem; +} +.kv dt { color: var(--muted); } +.kv dd { margin: 0; font-family: var(--mono); color: var(--fg-bright); } + +.pool-row { + display: flex; + justify-content: space-between; + padding: 0.45rem 0; + border-bottom: 1px solid var(--border); + gap: 0.8rem; + font-size: 0.85rem; +} +.pool-row:last-child { border-bottom: none; } +.pool-row .details { color: var(--muted); font-family: var(--mono); font-size: 0.78rem; } + +/* --- callouts ------------------------------------------------------------ */ +.callout { + border: 1px solid var(--border); + border-left: 3px solid var(--warn); + padding: 0.5rem 0.8rem; + background: var(--panel-2); + font-size: 0.85rem; + color: var(--warn); + font-family: var(--mono); + word-break: break-all; +} +.callout.err { border-left-color: var(--crit); color: var(--crit); } +.callout.ok { border-left-color: var(--ok); color: var(--ok); } + +/* --- utilities ----------------------------------------------------------- */ +.muted { color: var(--muted); } +.dim { color: var(--dim); } +.stack { display: flex; flex-direction: column; gap: 0.9rem; } +.stack.sm { gap: 0.45rem; } +.row { display: flex; gap: 0.6rem; align-items: center; } +.row.wrap { flex-wrap: wrap; } +.right { margin-left: auto; } +.center { text-align: center; } +.empty { + padding: 1.5rem; + text-align: center; + color: var(--muted); + font-size: 0.85rem; +} + +/* Login --------------------------------------------------------------------*/ +.login { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; +} +.login .panel { width: 100%; max-width: 22rem; } +.login .panel .body { padding: 1.2rem; display: flex; flex-direction: column; gap: 0.8rem; } +.login .brand { color: var(--fg-bright); font-weight: 600; letter-spacing: 0.03em; font-size: 1rem; } + +/* Flash --------------------------------------------------------------------*/ +.flash { + padding: 0.5rem 1rem; + border-left: 3px solid var(--warn); + background: var(--panel-2); + color: var(--warn); + font-size: 0.85rem; + margin: 0 1.2rem; +} +.flash.err { border-left-color: var(--crit); color: var(--crit); } +.flash.info { border-left-color: var(--link); color: var(--link); } diff --git a/server/lib/server_web/components/dashboard_nav.ex b/server/lib/server_web/components/dashboard_nav.ex new file mode 100644 index 0000000..d992762 --- /dev/null +++ b/server/lib/server_web/components/dashboard_nav.ex @@ -0,0 +1,26 @@ +defmodule ServerWeb.DashboardNav do + @moduledoc "Top navigation bar rendered inside authenticated LiveViews." + + use Phoenix.Component + use ServerWeb, :verified_routes + + attr :active, :atom, required: true, values: [:overview, :vms, :admin, :other] + + def nav(assigns) do + ~H""" + + """ + end + + defp link_cls(active, key) when active == key, do: "active" + defp link_cls(_, _), do: nil +end diff --git a/server/lib/server_web/components/layouts/app.html.heex b/server/lib/server_web/components/layouts/app.html.heex index e23bfc8..7a295c2 100644 --- a/server/lib/server_web/components/layouts/app.html.heex +++ b/server/lib/server_web/components/layouts/app.html.heex @@ -1,32 +1,7 @@ -
-
-
- - - -

- v<%= Application.spec(:phoenix, :vsn) %> -

-
- -
-
-
-
- <.flash_group flash={@flash} /> - <%= @inner_content %> -
-
+<%= if info = Phoenix.Flash.get(@flash, :info) do %> +
{info}
+<% end %> +<%= if err = Phoenix.Flash.get(@flash, :error) do %> +
{err}
+<% end %> +{@inner_content} diff --git a/server/lib/server_web/components/layouts/root.html.heex b/server/lib/server_web/components/layouts/root.html.heex index 89d883f..e24f11f 100644 --- a/server/lib/server_web/components/layouts/root.html.heex +++ b/server/lib/server_web/components/layouts/root.html.heex @@ -1,17 +1,17 @@ - + - <.live_title suffix=" · Phoenix Framework"> - <%= assigns[:page_title] || "Server" %> + <.live_title suffix=" · Proxmox Monitor"> + {assigns[:page_title] || "Dashboard"} - - <%= @inner_content %> + + {@inner_content} diff --git a/server/lib/server_web/controllers/auth_html/login.html.heex b/server/lib/server_web/controllers/auth_html/login.html.heex index 75f4226..47c5cef 100644 --- a/server/lib/server_web/controllers/auth_html/login.html.heex +++ b/server/lib/server_web/controllers/auth_html/login.html.heex @@ -7,36 +7,30 @@ Sign in · Proxmox Monitor - -
-
-

Proxmox Monitor

- - <%= if @error do %> -

{@error}

- <% end %> - -
- - -
""" end defp format_seen(nil), do: "never" - - defp format_seen(%DateTime{} = dt) do - Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC") - end + defp format_seen(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC") end diff --git a/server/lib/server_web/live/host_detail_live.ex b/server/lib/server_web/live/host_detail_live.ex index c70881f..56d97e4 100644 --- a/server/lib/server_web/live/host_detail_live.ex +++ b/server/lib/server_web/live/host_detail_live.ex @@ -16,7 +16,7 @@ defmodule ServerWeb.HostDetailLive do if connected?(socket), do: Phoenix.PubSub.subscribe(Server.PubSub, "metrics:#{host.id}") - {:ok, socket |> assign(:host, host) |> load_samples()} + {:ok, socket |> assign(host: host, page_title: host.name) |> load_samples()} end end @@ -38,141 +38,129 @@ defmodule ServerWeb.HostDetailLive do @impl true def render(assigns) do ~H""" -
-
+ + +
+
- <.link navigate={~p"/"} class="text-sm text-zinc-500 hover:text-zinc-900">← Back -

{@host.name}

-

- {sys_line(@slow)} · Uptime {uptime(@fast)} · Last seen {last_seen(@host.last_seen_at)} -

-
- status_class(@host.status)}> - {@host.status} - -
- -
-

Host metrics

- <.metric_row label="Load (1/5/15)" value={host_load(@fast)} /> - <.metric_row label="Memory" value={host_mem(@fast)} /> -
- -
-

ZFS pools

-

No data.

-
-
- {pool["name"]} - {pool["health"]} -
-
- Capacity {pool["capacity_percent"]}% · Fragmentation {pool["fragmentation_percent"] || 0}% · Errors {pool[ - "error_count" - ] || 0} · vdevs {pool["vdev_count"] || 0} (degraded {pool["degraded_vdev_count"] || 0}) · Last scrub {pool[ - "last_scrub_end" - ] || "never"} +

{@host.name}

+
+ {sys_line(@slow)} · uptime {uptime(@fast)} · last seen {last_seen(@host.last_seen_at)}
-
+ {@host.status} +
-
-

Snapshots

- - - - - - - - - - - - - - - - - -
DatasetCountOldestNewest
{ds["name"]}{ds["snapshot_count"]}{unix_to_date(ds["oldest_snapshot_unix"])}{unix_to_date(ds["newest_snapshot_unix"])}
-

No data.

-
+
+
Host
+
+
+
load (1/5/15)
{host_load(@fast)}
+
memory
{host_mem(@fast)}
+
agent
{@host.agent_version || "—"}
+
+
+
-
-

Storage

- - - - - - - - - - - - - - - -
NameTypeUsage
{s["name"]}{s["type"]}{storage_usage(s)}
-

No data.

-
+
+
ZFS pools{length(pools(@fast))}
+
+
No data.
+
+
+ + {pool["name"]} + + {pool["health"]} +
+ cap {pool["capacity_percent"]}% · + frag {pool["fragmentation_percent"] || 0}% · + err {pool["error_count"] || 0} · + vdevs {pool["vdev_count"] || 0} (deg {pool["degraded_vdev_count"] || 0}) +
+
+
+ scrub
{pool["last_scrub_end"] || "never"} +
+
+
+
-
-

VMs / LXCs

- - - - - - - - - - - - - - - - - -
VMIDNameTypeStatus
{vm["vmid"]}{vm["name"]}{vm["type"]}{vm["status"]}
-

No data.

-
+
+
Snapshots{length(datasets(@medium))}
+
+ + + + + + + + + + + + +
DatasetCountOldestNewest
{ds["name"]}{ds["snapshot_count"]}{unix_to_date(ds["oldest_snapshot_unix"])}{unix_to_date(ds["newest_snapshot_unix"])}
+
No data.
+
+
+ +
+
Storage{length(storages(@fast))}
+
+ + + + + + + + + + + +
NameTypeUsage
{s["name"]}{s["type"]}{storage_usage(s)}
+
No data.
+
+
+ +
+
VMs / LXCs{length(vms(@fast))}
+
+ + + + + + + + + + + + +
VMIDNameTypeStatus
{vm["vmid"]}{vm["name"]}{vm["type"]}{vm["status"]}
+
No data.
+
+
""" end - attr :label, :string, required: true - attr :value, :string, required: true + defp vm_status(%{"status" => "running"}), do: "ok" + defp vm_status(%{"status" => "stopped"}), do: "offline" + defp vm_status(_), do: "warning" - def metric_row(assigns) do - ~H""" -
- {@label} - {@value} -
- """ - end - - defp status_class("online"), do: "text-green-600" - defp status_class("offline"), do: "text-zinc-500" - defp status_class(_), do: "text-zinc-500" - - defp pool_class("ONLINE"), do: "text-green-600 font-mono" - defp pool_class(_), do: "text-red-600 font-mono" + defp pool_badge_style("ONLINE"), do: "color: var(--ok);" + defp pool_badge_style(_), do: "color: var(--crit);" defp sys_line(nil), do: "—" - defp sys_line(%{payload: p}) do get_in(p, ["system_info", "pve_version"]) || "—" end defp uptime(nil), do: "—" - defp uptime(%{payload: p}) do case get_in(p, ["host", "uptime_seconds"]) do nil -> "—" @@ -182,10 +170,8 @@ defmodule ServerWeb.HostDetailLive do end defp last_seen(nil), do: "never" - defp last_seen(%DateTime{} = dt) do secs = DateTime.diff(DateTime.utc_now(), dt, :second) - cond do secs < 60 -> "#{secs}s ago" secs < 3600 -> "#{div(secs, 60)}m ago" @@ -194,7 +180,6 @@ defmodule ServerWeb.HostDetailLive do end defp host_load(nil), do: "—" - defp host_load(%{payload: p}) do l1 = get_in(p, ["host", "load1"]) || "—" l5 = get_in(p, ["host", "load5"]) || "—" @@ -203,7 +188,6 @@ defmodule ServerWeb.HostDetailLive do end defp host_mem(nil), do: "—" - defp host_mem(%{payload: p}) do used = get_in(p, ["host", "mem_used_bytes"]) total = get_in(p, ["host", "mem_total_bytes"]) @@ -211,9 +195,7 @@ defmodule ServerWeb.HostDetailLive do case {used, total} do {u, t} when is_integer(u) and is_integer(t) and t > 0 -> "#{Float.round(u / t * 100, 1)}% (#{format_bytes(u)} / #{format_bytes(t)})" - - _ -> - "—" + _ -> "—" end end @@ -237,7 +219,6 @@ defmodule ServerWeb.HostDetailLive do defp storage_usage(_), do: "—" defp unix_to_date(nil), do: "—" - defp unix_to_date(unix) when is_integer(unix) do case DateTime.from_unix(unix) do {:ok, dt} -> Calendar.strftime(dt, "%Y-%m-%d") diff --git a/server/lib/server_web/live/overview_live.ex b/server/lib/server_web/live/overview_live.ex index 1732f82..5397dc3 100644 --- a/server/lib/server_web/live/overview_live.ex +++ b/server/lib/server_web/live/overview_live.ex @@ -6,7 +6,7 @@ defmodule ServerWeb.OverviewLive do @impl true def mount(_params, _session, socket) do if connected?(socket), do: Phoenix.PubSub.subscribe(Server.PubSub, "metrics") - {:ok, assign(socket, :hosts, load_hosts())} + {:ok, assign(socket, hosts: load_hosts(), page_title: "Overview")} end @impl true @@ -25,70 +25,59 @@ defmodule ServerWeb.OverviewLive do @impl true def render(assigns) do ~H""" -
-
-
-

Proxmox Monitor

- <.link navigate={~p"/vms"} class="text-sm text-zinc-600 hover:text-zinc-900">VMs - <.link navigate={~p"/admin/hosts"} class="text-sm text-zinc-600 hover:text-zinc-900"> - Admin - -
- <.link href={~p"/logout"} method="delete" class="text-sm text-zinc-500">Sign out -
+ -
-
border_class(entry.status)} - > - <.link navigate={~p"/hosts/#{entry.host.name}"} class="block space-y-2"> -
- {entry.host.name} - text_class(entry.status)}> - {entry.status} - -
-
- Last seen: {last_seen(entry.host.last_seen_at)} -
-
-
Load: {format_load(entry.sample.payload)}
-
RAM used: {format_mem(entry.sample.payload)}
-
Pools: {pool_summary(entry.sample.payload)}
-
VMs: {vm_count(entry.sample.payload)}
-
-
- No samples yet -
- +
+
+

Hosts

+ {summary_line(@hosts)} +
+ +
+
+ No hosts registered. Add one at + <.link navigate={~p"/admin/hosts"}>/admin/hosts.
-

- No hosts registered yet. Add one via /admin/hosts. -

+
+ <.link :for={entry <- @hosts} navigate={~p"/hosts/#{entry.host.name}"} class="card" + data-status={Atom.to_string(entry.status)} data-role="host-card"> +
+ {entry.host.name} + + {entry.status} + +
+
last seen {last_seen(entry.host.last_seen_at)}
+ <%= if entry.sample do %> +
load{load(entry.sample.payload)}
+
ram{mem(entry.sample.payload)}
+
pools{pool_summary(entry.sample.payload)}
+
vms{vm_count(entry.sample.payload)}
+ <% else %> +
no samples yet
+ <% end %> + +
""" end - defp border_class(:ok), do: "border-green-500" - defp border_class(:warning), do: "border-yellow-500" - defp border_class(:critical), do: "border-red-500" - defp border_class(:offline), do: "border-zinc-400" - - defp text_class(:ok), do: "text-green-600" - defp text_class(:warning), do: "text-yellow-600" - defp text_class(:critical), do: "text-red-600" - defp text_class(:offline), do: "text-zinc-500" + defp summary_line([]), do: "0 hosts" + defp summary_line(hosts) do + n = length(hosts) + by = Enum.frequencies_by(hosts, & &1.status) + parts = + [:ok, :warning, :critical, :offline] + |> Enum.filter(&Map.has_key?(by, &1)) + |> Enum.map(fn s -> "#{by[s]} #{s}" end) + "#{n} host#{if n == 1, do: "", else: "s"} · " <> Enum.join(parts, " · ") + end defp last_seen(nil), do: "never" - defp last_seen(%DateTime{} = dt) do secs = DateTime.diff(DateTime.utc_now(), dt, :second) - cond do secs < 60 -> "#{secs}s ago" secs < 3600 -> "#{div(secs, 60)}m ago" @@ -96,23 +85,20 @@ defmodule ServerWeb.OverviewLive do end end - defp format_load(payload) do + defp load(payload) do case get_in(payload, ["host", "load1"]) do nil -> "—" l -> :io_lib.format("~.2f", [l]) |> to_string() end end - defp format_mem(payload) do + defp mem(payload) do used = get_in(payload, ["host", "mem_used_bytes"]) total = get_in(payload, ["host", "mem_total_bytes"]) - case {used, total} do {u, t} when is_integer(u) and is_integer(t) and t > 0 -> "#{Float.round(u / t * 100, 1)}%" - - _ -> - "—" + _ -> "—" end end @@ -120,11 +106,11 @@ defmodule ServerWeb.OverviewLive do pools = get_in(payload, ["zfs_pools", "pools"]) || [] total = length(pools) bad = Enum.count(pools, &(&1["health"] != "ONLINE")) - if total == 0, do: "—", else: "#{total - bad}/#{total} ok" + if total == 0, do: "—", else: "#{total - bad}/#{total}" end defp vm_count(payload) do vms = get_in(payload, ["vms_runtime", "vms"]) || [] - length(vms) + length(vms) |> to_string() end end diff --git a/server/lib/server_web/live/vm_search_live.ex b/server/lib/server_web/live/vm_search_live.ex index 5c9f51c..ec36262 100644 --- a/server/lib/server_web/live/vm_search_live.ex +++ b/server/lib/server_web/live/vm_search_live.ex @@ -6,7 +6,7 @@ defmodule ServerWeb.VmSearchLive do @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())} + {:ok, socket |> assign(q: "", vms: load_vms(), page_title: "VM Search")} end @impl true @@ -54,51 +54,57 @@ defmodule ServerWeb.VmSearchLive do @impl true def render(assigns) do ~H""" -
- <.link navigate={~p"/"} class="text-sm text-zinc-500 hover:text-zinc-900">← Back -

VM Search

+ + +
+
+

VM Search

+ {length(filter(@vms, @q))} / {length(@vms)} shown +
- - - - - - - - - - - - - - - - - - - - - - -
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.
+
+
+ + + + + + + + + + + + + + + + + + + + + +
VMIDNameHostTypeStatusIPs
{vm.vmid}{vm.name}<.link navigate={~p"/hosts/#{vm.host_name}"}>{vm.host_name}{vm.type}{vm.status}{Enum.join(vm.ips, ", ")}
+
No matches.
+
+
""" end + + defp vm_status(%{status: "running"}), do: "ok" + defp vm_status(%{status: "stopped"}), do: "offline" + defp vm_status(_), do: "warning" end