Initial commit: homelab dashboard

Elixir + Phoenix dashboard displaying public and internal homelab
services. Internal services are shown only when the request originates
from the local network, detected server-side via conn.remote_ip with
Traefik trusted as a proxy (RemoteIp plug).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adyrem
2026-05-18 16:02:29 +02:00
commit f92c2247a0
47 changed files with 3874 additions and 0 deletions
@@ -0,0 +1,24 @@
defmodule HomelabDashboardWeb.ErrorHTML do
@moduledoc """
This module is invoked by your endpoint in case of errors on HTML requests.
See config/config.exs.
"""
use HomelabDashboardWeb, :html
# If you want to customize your error pages,
# uncomment the embed_templates/1 call below
# and add pages to the error directory:
#
# * lib/homelab_dashboard_web/controllers/error_html/404.html.heex
# * lib/homelab_dashboard_web/controllers/error_html/500.html.heex
#
# embed_templates "error_html/*"
# The default is to render a plain text page based on
# the template name. For example, "404.html" becomes
# "Not Found".
def render(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end
@@ -0,0 +1,21 @@
defmodule HomelabDashboardWeb.ErrorJSON do
@moduledoc """
This module is invoked by your endpoint in case of errors on JSON requests.
See config/config.exs.
"""
# If you want to customize a particular status code,
# you may add your own clauses, such as:
#
# def render("500.json", _assigns) do
# %{errors: %{detail: "Internal Server Error"}}
# end
# By default, Phoenix returns the status message from
# the template name. For example, "404.json" becomes
# "Not Found".
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end
@@ -0,0 +1,95 @@
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-puzzle-piece"
},
%{
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"
},
%{
name: "Grafana",
url: "https://grafana.homelab",
description: "Metrics & monitoring dashboards",
icon: "hero-chart-bar"
},
%{
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-film"
},
%{
name: "Jellyseerr",
url: "https://jellyseerr.homelab",
description: "Media request management",
icon: "hero-ticket"
},
%{
name: "Radarr",
url: "https://radarr.homelab",
description: "Movie collection manager",
icon: "hero-video-camera"
},
%{
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-magnifying-glass"
},
%{
name: "qBittorrent",
url: "https://qbittorrent.homelab",
description: "Torrent client",
icon: "hero-arrow-down-tray"
}
]
def home(conn, _params) do
render(conn, :home,
public_services: @public_services,
internal_services: @internal_services,
show_internal: internal_network?(conn.remote_ip)
)
end
defp internal_network?({127, _, _, _}), do: true
defp internal_network?({10, _, _, _}), do: true
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
defp internal_network?(_), do: false
end
@@ -0,0 +1,10 @@
defmodule HomelabDashboardWeb.PageHTML do
@moduledoc """
This module contains pages rendered by PageController.
See the `page_html` directory for all templates available.
"""
use HomelabDashboardWeb, :html
embed_templates "page_html/*"
end
@@ -0,0 +1,76 @@
<Layouts.flash_group flash={@flash} />
<div class="min-h-screen bg-base-100">
<header class="border-b border-base-300 bg-base-200">
<div class="mx-auto max-w-5xl px-6 py-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="hero-home h-7 w-7 text-primary"></span>
<h1 class="text-xl font-bold tracking-tight">Homelab Dashboard</h1>
</div>
<Layouts.theme_toggle />
</div>
</header>
<main class="mx-auto max-w-5xl px-6 py-10 space-y-10">
<section>
<h2 class="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-4">
Public Services
</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<%= for service <- @public_services do %>
<a
href={service.url}
target="_blank"
rel="noopener noreferrer"
class="group flex items-start gap-4 rounded-box bg-base-200 p-5 transition hover:bg-base-300 hover:shadow-md"
>
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-btn bg-primary/10 text-primary group-hover:bg-primary/20 transition">
<span class={[service.icon, "h-5 w-5"]}></span>
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-semibold"><%= service.name %></span>
<span class="hero-arrow-top-right-on-square h-3.5 w-3.5 text-base-content/30 group-hover:text-primary transition"></span>
</div>
<p class="mt-0.5 text-sm text-base-content/60 leading-snug"><%= service.description %></p>
</div>
</a>
<% end %>
</div>
</section>
<%= if @show_internal do %>
<section>
<div class="flex items-center gap-3 mb-4">
<h2 class="text-xs font-semibold uppercase tracking-widest text-base-content/50">
Internal Services
</h2>
<span class="badge badge-success badge-sm gap-1">
<span class="inline-block h-1.5 w-1.5 rounded-full bg-success-content"></span>
Local network
</span>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<%= for service <- @internal_services do %>
<a
href={service.url}
target="_blank"
rel="noopener noreferrer"
class="group flex items-start gap-4 rounded-box bg-base-200 p-5 transition hover:bg-base-300 hover:shadow-md"
>
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-btn bg-secondary/10 text-secondary group-hover:bg-secondary/20 transition">
<span class={[service.icon, "h-5 w-5"]}></span>
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-semibold"><%= service.name %></span>
<span class="hero-arrow-top-right-on-square h-3.5 w-3.5 text-base-content/30 group-hover:text-secondary transition"></span>
</div>
<p class="mt-0.5 text-sm text-base-content/60 leading-snug"><%= service.description %></p>
</div>
</a>
<% end %>
</div>
</section>
<% end %>
</main>
</div>