diff --git a/lib/homelab_dashboard/application.ex b/lib/homelab_dashboard/application.ex index 4c344aa..89ca3bd 100644 --- a/lib/homelab_dashboard/application.ex +++ b/lib/homelab_dashboard/application.ex @@ -11,9 +11,7 @@ defmodule HomelabDashboard.Application do HomelabDashboardWeb.Telemetry, {DNSCluster, query: Application.get_env(:homelab_dashboard, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: HomelabDashboard.PubSub}, - # Start a worker by calling: HomelabDashboard.Worker.start_link(arg) - # {HomelabDashboard.Worker, arg}, - # Start to serve requests, typically the last entry + HomelabDashboard.ServicesSync, HomelabDashboardWeb.Endpoint ] diff --git a/lib/homelab_dashboard/services_sync.ex b/lib/homelab_dashboard/services_sync.ex new file mode 100644 index 0000000..fc88681 --- /dev/null +++ b/lib/homelab_dashboard/services_sync.ex @@ -0,0 +1,153 @@ +defmodule HomelabDashboard.ServicesSync do + use GenServer + require Logger + + @services_file "ansible/roles/traefik/defaults/main.yml" + @sync_interval :timer.minutes(5) + + @icons %{ + "mtg" => "hero-sparkles", + "gitea" => "hero-code-bracket", + "proxmox" => "hero-server-stack", + "grafana" => "hero-presentation-chart-line", + "pihole" => "hero-shield-check", + "jellyfin" => "hero-play-circle", + "jellyseerr" => "hero-queue-list", + "radarr" => "hero-film", + "sonarr" => "hero-tv", + "prowlarr" => "hero-funnel", + "qbittorrent" => "hero-cloud-arrow-down" + } + + @descriptions %{ + "mtg" => "Magic: The Gathering collection", + "gitea" => "Git repository hosting", + "proxmox" => "Virtualization & container management", + "grafana" => "Metrics & monitoring dashboards", + "pihole" => "Network-wide DNS ad blocker", + "jellyfin" => "Media server", + "jellyseerr" => "Media request management", + "radarr" => "Movie collection manager", + "sonarr" => "TV series collection manager", + "prowlarr" => "Indexer manager", + "qbittorrent" => "Torrent client" + } + + @display_names %{ + "mtg" => "MTG", + "pihole" => "Pi-hole", + "qbittorrent" => "qBittorrent" + } + + @path_suffixes %{ + "pihole" => "/admin" + } + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + def get_services do + GenServer.call(__MODULE__, :get_services) + end + + @impl true + def init(_opts) do + {public, internal} = load_from_disk() + schedule_sync() + {:ok, %{public_services: public, internal_services: internal}} + end + + @impl true + def handle_call(:get_services, _from, state) do + {:reply, {state.public_services, state.internal_services}, state} + end + + @impl true + def handle_info(:sync, state) do + git_pull() + + new_state = + case load_from_disk() do + {[], []} -> + Logger.warning("ServicesSync: parsed empty service list, retaining previous state") + state + + {public, internal} -> + %{state | public_services: public, internal_services: internal} + end + + schedule_sync() + {:noreply, new_state} + end + + defp schedule_sync do + Process.send_after(self(), :sync, @sync_interval) + end + + defp git_pull do + repo = homelab_repo() + + case System.cmd("git", ["pull"], cd: repo, stderr_to_stdout: true) do + {output, 0} -> + Logger.info("ServicesSync git pull: #{String.trim(output)}") + + {output, code} -> + Logger.warning("ServicesSync git pull failed (exit #{code}): #{String.trim(output)}") + end + end + + defp load_from_disk do + path = Path.join(homelab_repo(), @services_file) + + case YamlElixir.read_from_file(path) do + {:ok, data} -> + data + |> Map.get("traefik_services", []) + |> parse_services() + + {:error, reason} -> + Logger.error("ServicesSync failed to parse YAML: #{inspect(reason)}") + {[], []} + end + end + + defp parse_services(services) do + {public_raw, internal_raw} = Enum.split_with(services, &Map.has_key?(&1, "public_host")) + + public = + Enum.map(public_raw, fn svc -> + name = svc["name"] + path = svc["public_path"] || "" + + %{ + name: display_name(name), + url: "https://#{svc["public_host"]}#{path}", + description: @descriptions[name] || String.capitalize(name), + icon: @icons[name] || "hero-globe-alt" + } + end) + + internal = + Enum.map(internal_raw, fn svc -> + name = svc["name"] + suffix = @path_suffixes[name] || "" + + %{ + name: display_name(name), + url: "https://#{svc["host"]}#{suffix}", + description: @descriptions[name] || String.capitalize(name), + icon: @icons[name] || "hero-globe-alt" + } + end) + + {public, internal} + end + + defp display_name(name), do: @display_names[name] || String.capitalize(name) + + defp homelab_repo do + Application.get_env(:homelab_dashboard, :homelab_repo) || + Path.join(System.get_env("HOME", "/root"), "dev/homelab") + end +end diff --git a/lib/homelab_dashboard_web/controllers/page_controller.ex b/lib/homelab_dashboard_web/controllers/page_controller.ex index bdbb8bc..00e9d7d 100644 --- a/lib/homelab_dashboard_web/controllers/page_controller.ex +++ b/lib/homelab_dashboard_web/controllers/page_controller.ex @@ -2,82 +2,12 @@ defmodule HomelabDashboardWeb.PageController do use HomelabDashboardWeb, :controller import Bitwise - @public_services [ - %{ - name: "MTG", - url: "https://adyrem.duckdns.org/mtg", - description: "Magic: The Gathering collection", - icon: "hero-sparkles" - }, - %{ - name: "Gitea", - url: "https://adyrem.duckdns.org/git", - description: "Git repository hosting", - icon: "hero-code-bracket" - } - ] - - @internal_services [ - %{ - name: "Proxmox", - url: "https://proxmox.homelab", - description: "Virtualization & container management", - icon: "hero-server-stack" - }, - %{ - name: "Grafana", - url: "https://grafana.homelab", - description: "Metrics & monitoring dashboards", - icon: "hero-presentation-chart-line" - }, - %{ - name: "Pi-hole", - url: "https://pihole.homelab/admin", - description: "Network-wide DNS ad blocker", - icon: "hero-shield-check" - }, - %{ - name: "Jellyfin", - url: "https://jellyfin.homelab", - description: "Media server", - icon: "hero-play-circle" - }, - %{ - name: "Jellyseerr", - url: "https://jellyseerr.homelab", - description: "Media request management", - icon: "hero-queue-list" - }, - %{ - name: "Radarr", - url: "https://radarr.homelab", - description: "Movie collection manager", - icon: "hero-film" - }, - %{ - name: "Sonarr", - url: "https://sonarr.homelab", - description: "TV series collection manager", - icon: "hero-tv" - }, - %{ - name: "Prowlarr", - url: "https://prowlarr.homelab", - description: "Indexer manager", - icon: "hero-funnel" - }, - %{ - name: "qBittorrent", - url: "https://qbittorrent.homelab", - description: "Torrent client", - icon: "hero-cloud-arrow-down" - } - ] - def home(conn, _params) do + {public_services, internal_services} = HomelabDashboard.ServicesSync.get_services() + render(conn, :home, - public_services: @public_services, - internal_services: @internal_services, + public_services: public_services, + internal_services: internal_services, show_internal: internal_network?(conn.remote_ip) ) end @@ -87,7 +17,6 @@ defmodule HomelabDashboardWeb.PageController do defp internal_network?({192, 168, _, _}), do: true defp internal_network?({172, b, _, _}) when b in 16..31, do: true defp internal_network?({0, 0, 0, 0, 0, 0, 0, 1}), do: true - # IPv6-mapped IPv4: ::ffff:x.x.x.x — decode and re-check defp internal_network?({0, 0, 0, 0, 0, 65535, ab, cd}) do internal_network?({ab >>> 8, ab &&& 0xFF, cd >>> 8, cd &&& 0xFF}) end diff --git a/mix.exs b/mix.exs index 2d70086..7aa304d 100644 --- a/mix.exs +++ b/mix.exs @@ -63,7 +63,8 @@ defmodule HomelabDashboard.MixProject do {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.2.0"}, {:bandit, "~> 1.5"}, - {:remote_ip, "~> 1.2"} + {:remote_ip, "~> 1.2"}, + {:yaml_elixir, "~> 2.9"} ] end diff --git a/mix.lock b/mix.lock index 41799b6..36462f7 100644 --- a/mix.lock +++ b/mix.lock @@ -39,4 +39,6 @@ "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "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"}, + "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"}, }