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:
@@ -11,9 +11,7 @@ defmodule HomelabDashboard.Application do
|
|||||||
HomelabDashboardWeb.Telemetry,
|
HomelabDashboardWeb.Telemetry,
|
||||||
{DNSCluster, query: Application.get_env(:homelab_dashboard, :dns_cluster_query) || :ignore},
|
{DNSCluster, query: Application.get_env(:homelab_dashboard, :dns_cluster_query) || :ignore},
|
||||||
{Phoenix.PubSub, name: HomelabDashboard.PubSub},
|
{Phoenix.PubSub, name: HomelabDashboard.PubSub},
|
||||||
# Start a worker by calling: HomelabDashboard.Worker.start_link(arg)
|
HomelabDashboard.ServicesSync,
|
||||||
# {HomelabDashboard.Worker, arg},
|
|
||||||
# Start to serve requests, typically the last entry
|
|
||||||
HomelabDashboardWeb.Endpoint
|
HomelabDashboardWeb.Endpoint
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
use HomelabDashboardWeb, :controller
|
||||||
import Bitwise
|
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
|
def home(conn, _params) do
|
||||||
|
{public_services, internal_services} = HomelabDashboard.ServicesSync.get_services()
|
||||||
|
|
||||||
render(conn, :home,
|
render(conn, :home,
|
||||||
public_services: @public_services,
|
public_services: public_services,
|
||||||
internal_services: @internal_services,
|
internal_services: internal_services,
|
||||||
show_internal: internal_network?(conn.remote_ip)
|
show_internal: internal_network?(conn.remote_ip)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@@ -87,7 +17,6 @@ defmodule HomelabDashboardWeb.PageController do
|
|||||||
defp internal_network?({192, 168, _, _}), do: true
|
defp internal_network?({192, 168, _, _}), do: true
|
||||||
defp internal_network?({172, b, _, _}) when b in 16..31, 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
|
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
|
defp internal_network?({0, 0, 0, 0, 0, 65535, ab, cd}) do
|
||||||
internal_network?({ab >>> 8, ab &&& 0xFF, cd >>> 8, cd &&& 0xFF})
|
internal_network?({ab >>> 8, ab &&& 0xFF, cd >>> 8, cd &&& 0xFF})
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ defmodule HomelabDashboard.MixProject do
|
|||||||
{:jason, "~> 1.2"},
|
{:jason, "~> 1.2"},
|
||||||
{:dns_cluster, "~> 0.2.0"},
|
{:dns_cluster, "~> 0.2.0"},
|
||||||
{:bandit, "~> 1.5"},
|
{:bandit, "~> 1.5"},
|
||||||
{:remote_ip, "~> 1.2"}
|
{:remote_ip, "~> 1.2"},
|
||||||
|
{:yaml_elixir, "~> 2.9"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -39,4 +39,6 @@
|
|||||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
"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": {: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"},
|
"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"},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user