Auto-sync services from homelab repo via GenServer

ServicesSync polls ~/dev/homelab every 5 minutes: runs git pull, then
re-parses ansible/roles/traefik/defaults/main.yml to derive public and
internal service lists. Services with public_host are shown publicly;
others require internal network access. PageController now delegates to
ServicesSync instead of hardcoded module attributes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adyrem
2026-05-19 15:08:30 +02:00
parent fd207968fb
commit 9461285318
5 changed files with 162 additions and 79 deletions
+1 -3
View File
@@ -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
]
+153
View File
@@ -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
@@ -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