Add Homepage dashboard on Proxmox with Palantir theme and Admin UI.
Deploy gethomepage on pve CT 120, categorized services from Homarr, RSS feeds, custom styling, and a browser-based admin UI on the NAS for adding sites. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate Homepage YAML from Homarr default.json with smart categorization."""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent
|
||||
HOMARR = _ROOT.parent / "homarr" / "config" / "default.json"
|
||||
OUT = _ROOT / "config"
|
||||
WEBSITES_ADD = _ROOT / "websites-add.txt"
|
||||
|
||||
ICON_MAP = {
|
||||
"adguard": "adguard-home.png",
|
||||
"gitea": "gitea.png",
|
||||
"grafana": "grafana.png",
|
||||
"prometheus": "prometheus.png",
|
||||
"portainer": "portainer.png",
|
||||
"postgres": "postgres.png",
|
||||
"neo4j": "neo4j.png",
|
||||
"home assistant": "home-assistant.png",
|
||||
"ha ": "home-assistant.png",
|
||||
"unifi": "unifi.png",
|
||||
"nginx": "nginx-proxy-manager.png",
|
||||
"vaultwarden": "vaultwarden.png",
|
||||
"immich": "immich.png",
|
||||
"n8n": "n8n.png",
|
||||
"sonarr": "sonarr.png",
|
||||
"radarr": "radarr.png",
|
||||
"prowlarr": "prowlarr.png",
|
||||
"qbittorrent": "qbittorrent.png",
|
||||
"sabnzbd": "sabnzbd.png",
|
||||
"nextcloud": "nextcloud.png",
|
||||
"excalidraw": "excalidraw.png",
|
||||
"wazuh": "wazuh.png",
|
||||
"truenas": "truenas.png",
|
||||
"frigate": "frigate.png",
|
||||
"linkwarden": "linkwarden.png",
|
||||
"uptime": "uptime-kuma.png",
|
||||
"changedetection": "changedetection.png",
|
||||
"traccar": "traccar.png",
|
||||
"tunarr": "tunarr.png",
|
||||
"metube": "metube.png",
|
||||
"remotely": "remotely.png",
|
||||
"homarr": "homarr.png",
|
||||
"chatgpt": "si-openai",
|
||||
"github": "si-github",
|
||||
"anthropic": "si-anthropic",
|
||||
"deepseek": "si-deepseek",
|
||||
"grok": "si-x",
|
||||
"youtube": "si-youtube",
|
||||
"proxmox": "proxmox.png",
|
||||
}
|
||||
|
||||
# Hex colors per group for mdi/si icons
|
||||
GROUP_ICON_COLORS = {
|
||||
"Infrastructure": "#3b82f6",
|
||||
"Media & TV": "#a855f7",
|
||||
"Smart Home": "#22c55e",
|
||||
"Productivity": "#06b6d4",
|
||||
"Tools & Utils": "#f59e0b",
|
||||
"AI Assistants": "#ec4899",
|
||||
"Dev & Docs": "#14b8a6",
|
||||
"Web Design": "#f97316",
|
||||
}
|
||||
|
||||
# Service groups → Homepage tab
|
||||
SERVICE_GROUPS = [
|
||||
"Infrastructure",
|
||||
"Media & TV",
|
||||
"Smart Home",
|
||||
"Productivity",
|
||||
"Tools & Utils",
|
||||
"AI Assistants",
|
||||
"Dev & Docs",
|
||||
"Web Design",
|
||||
]
|
||||
|
||||
HOMELAB_RULES = [
|
||||
("Infrastructure", [
|
||||
"proxmox", "portainer", "truenas", " adguard", "unifi", "wazuh", "idrac",
|
||||
"webmin", "pve script", "modem", "rackula", "mo-nas", "nginx", "nas",
|
||||
"lxc", "browserless", "remotely", "minarca", "change detection",
|
||||
"changedetection", "dsm on", "dsm aux", "turnkey", "synology stuff",
|
||||
]),
|
||||
("Media & TV", [
|
||||
"tunarr", "immich", "frigate", "metube", "nodecast", "nzb geek",
|
||||
"usenetdeal",
|
||||
]),
|
||||
("Smart Home", [
|
||||
" ha", "home assistant", "ring", "mawaqit", "home control", "ha voice",
|
||||
]),
|
||||
("Productivity", [
|
||||
"nextcloud", "vaultwarden", "n8n", "gitea", "excalidraw", "linkwarden",
|
||||
"invoice", "onlyoffice", "neo4j", "crm", "resume", "openresume",
|
||||
"bentopdf", "vert", "moocup", "thunderbird", "noted apps",
|
||||
]),
|
||||
]
|
||||
|
||||
RSS_BOOKMARKS = {
|
||||
"Tech News": [
|
||||
("Hacker News", "https://hnrss.org/frontpage"),
|
||||
("Ars Technica", "https://feeds.arstechnica.com/arstechnica/index"),
|
||||
("The Verge", "https://www.theverge.com/rss/index.xml"),
|
||||
("Lobsters", "https://lobste.rs/rss"),
|
||||
],
|
||||
"Security": [
|
||||
("BleepingComputer", "https://www.bleepingcomputer.com/feed/"),
|
||||
("Krebs on Security", "https://krebsonsecurity.com/feed/"),
|
||||
("The Hacker News", "https://feeds.feedburner.com/TheHackersNews"),
|
||||
],
|
||||
"Homelab": [
|
||||
("selfh.st", "https://selfh.st/rss/"),
|
||||
("Proxmox Forum", "https://forum.proxmox.com/forums/-/index.rss"),
|
||||
("r/selfhosted", "https://www.reddit.com/r/selfhosted/.rss"),
|
||||
("Docker Blog", "https://www.docker.com/blog/feed/"),
|
||||
("LinuxServer.io", "https://www.linuxserver.io/blog/rss/"),
|
||||
("Marius Hosting", "https://mariushosting.com/feed/"),
|
||||
],
|
||||
"AI & Dev": [
|
||||
("OpenAI Blog", "https://openai.com/blog/rss.xml"),
|
||||
("Hugging Face Blog", "https://huggingface.co/blog/feed.xml"),
|
||||
("GitHub Blog", "https://github.blog/feed/"),
|
||||
],
|
||||
"Blogs": [
|
||||
("Jack van lightly", "https://jack-vanlightly.com/feed.xml"),
|
||||
("Juhache", "https://juhache.substack.com/feed"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def slug_name(name: str) -> str:
|
||||
s = re.sub(r"[^a-zA-Z0-9]+", "-", name).strip("-").lower()
|
||||
return s or "service"
|
||||
|
||||
|
||||
def guess_icon(name: str, url: str) -> str:
|
||||
n = name.lower()
|
||||
for key, icon in ICON_MAP.items():
|
||||
if key in n:
|
||||
return icon
|
||||
host = urlparse(url).hostname or ""
|
||||
if host:
|
||||
part = host.split(".")[0]
|
||||
if part in ("192", "www"):
|
||||
return "mdi-server-network"
|
||||
return f"mdi-{part[:12]}"
|
||||
return "mdi-circle-outline"
|
||||
|
||||
|
||||
def colorize_icon(icon: str, group: str) -> str:
|
||||
"""Add hex color to vector icons for vivid Homepage gradients."""
|
||||
if "#" in icon or icon.endswith((".png", ".webp", ".svg")) or icon.startswith("http"):
|
||||
return icon
|
||||
color = GROUP_ICON_COLORS.get(group, "#22d3ee")
|
||||
if icon.startswith(("mdi-", "si-")):
|
||||
return f"{icon}-{color}"
|
||||
return icon
|
||||
|
||||
|
||||
def classify_homelab(name: str, url: str) -> str:
|
||||
blob = f" {name.lower()} {url.lower()} "
|
||||
for group, keys in HOMELAB_RULES:
|
||||
if any(k in blob for k in keys):
|
||||
return group
|
||||
return "Tools & Utils"
|
||||
|
||||
|
||||
def classify_app(name: str, url: str, homarr_cat: str) -> str:
|
||||
if homarr_cat == "AI":
|
||||
return "AI Assistants"
|
||||
if homarr_cat == "HTML5":
|
||||
return "Web Design"
|
||||
if homarr_cat in ("Other", "Data Stuff", "Projects"):
|
||||
return "Dev & Docs"
|
||||
if homarr_cat == "HomeLab Stuff":
|
||||
return classify_homelab(name, url)
|
||||
return "Tools & Utils"
|
||||
|
||||
|
||||
def yaml_escape(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def load_websites_extra() -> dict:
|
||||
"""Lees websites-add.txt: groep|naam|url per regel."""
|
||||
extra = {g: [] for g in SERVICE_GROUPS}
|
||||
if not WEBSITES_ADD.exists():
|
||||
return extra
|
||||
for raw in WEBSITES_ADD.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = [p.strip() for p in line.split("|", 2)]
|
||||
if len(parts) != 3:
|
||||
continue
|
||||
group, name, url = parts
|
||||
if group not in extra:
|
||||
print(f" Waarschuwing: onbekende groep '{group}' — regel overgeslagen")
|
||||
continue
|
||||
if name and url:
|
||||
extra[group].append((name, url))
|
||||
return extra
|
||||
|
||||
|
||||
def merge_groups(base: dict, extra: dict) -> dict:
|
||||
"""Homarr + handmatige sites; extra overschrijft zelfde naam in zelfde groep."""
|
||||
out = {g: list(base.get(g, [])) for g in SERVICE_GROUPS}
|
||||
for group, items in extra.items():
|
||||
if group not in out:
|
||||
out[group] = []
|
||||
names = {n.lower() for n, _ in out[group]}
|
||||
for name, url in items:
|
||||
key = name.lower()
|
||||
if key in names:
|
||||
out[group] = [(n, u) for n, u in out[group] if n.lower() != key]
|
||||
names.discard(key)
|
||||
out[group].append((name, url))
|
||||
names.add(key)
|
||||
return out
|
||||
|
||||
|
||||
def write_services(by_group: dict) -> str:
|
||||
lines = []
|
||||
for group in SERVICE_GROUPS:
|
||||
items = by_group.get(group, [])
|
||||
if not items:
|
||||
continue
|
||||
lines.append(f"- {group}:")
|
||||
seen = set()
|
||||
for name, url in items:
|
||||
key = slug_name(name)
|
||||
if key in seen:
|
||||
key = f"{key}-{len(seen)}"
|
||||
seen.add(key)
|
||||
icon = colorize_icon(guess_icon(name, url), group)
|
||||
lines.append(f" - {name}:")
|
||||
lines.append(f" icon: {icon}")
|
||||
lines.append(f" href: {url}")
|
||||
lines.append(f" description: {yaml_escape(name)}")
|
||||
try:
|
||||
p = urlparse(url)
|
||||
if p.scheme and p.hostname and re.match(r"^\d+\.\d+\.\d+\.\d+$", p.hostname):
|
||||
base = f"{p.scheme}://{p.netloc}"
|
||||
lines.append(f" siteMonitor: {base}")
|
||||
lines.append(" statusStyle: dot")
|
||||
except Exception:
|
||||
pass
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def write_settings() -> str:
|
||||
layout = {
|
||||
"Infrastructure": {"tab": "Ops", "icon": "mdi-server-network", "columns": 4, "style": "row"},
|
||||
"Media & TV": {"tab": "Media", "icon": "mdi-television-play", "columns": 4, "style": "row"},
|
||||
"Smart Home": {"tab": "Home", "icon": "mdi-home-automation", "columns": 3, "style": "row"},
|
||||
"Productivity": {"tab": "Work", "icon": "mdi-briefcase-outline", "columns": 4, "style": "row"},
|
||||
"Tools & Utils": {"tab": "Ops", "icon": "mdi-toolbox-outline", "columns": 3, "style": "row"},
|
||||
"AI Assistants": {"tab": "AI", "icon": "mdi-brain", "columns": 4, "style": "row"},
|
||||
"Dev & Docs": {"tab": "AI", "icon": "mdi-book-open-page-variant", "columns": 3, "style": "row"},
|
||||
"Web Design": {"tab": "AI", "icon": "mdi-palette-outline", "columns": 3, "style": "row"},
|
||||
"Tech News": {"tab": "Feeds", "icon": "mdi-newspaper-variant-outline", "columns": 2},
|
||||
"Security": {"tab": "Feeds", "icon": "mdi-shield-alert-outline", "columns": 2},
|
||||
"Homelab": {"tab": "Feeds", "icon": "mdi-server", "columns": 3},
|
||||
"AI & Dev": {"tab": "Feeds", "icon": "mdi-robot-outline", "columns": 2},
|
||||
"Blogs": {"tab": "Feeds", "icon": "mdi-rss", "columns": 2},
|
||||
}
|
||||
lines = [
|
||||
"---",
|
||||
"title: EL-KADI OPS",
|
||||
"description: Homelab command surface",
|
||||
"theme: dark",
|
||||
"color: slate",
|
||||
"iconStyle: gradient",
|
||||
"headerStyle: underlined",
|
||||
"useEqualHeights: true",
|
||||
"statusStyle: dot",
|
||||
"fullWidth: true",
|
||||
"cardBlur: md",
|
||||
"hideVersion: false",
|
||||
"quicklaunch:",
|
||||
" searchDescriptions: true",
|
||||
" hideInternetSearch: false",
|
||||
" showSearchSuggestions: true",
|
||||
"layout:",
|
||||
]
|
||||
for name, opts in layout.items():
|
||||
lines.append(f" {name}:")
|
||||
for k, v in opts.items():
|
||||
lines.append(f" {k}: {v}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def write_bookmarks() -> str:
|
||||
lines = ["---"]
|
||||
for group, items in RSS_BOOKMARKS.items():
|
||||
lines.append(f"- {group}:")
|
||||
for name, url in items:
|
||||
abbr = "".join(w[0] for w in re.findall(r"[A-Za-z0-9]+", name)[:2]).upper()[:2] or "RS"
|
||||
lines.append(f" - {name}:")
|
||||
lines.append(f" - abbr: {abbr}")
|
||||
lines.append(f" href: {url}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def write_widgets() -> str:
|
||||
return """---
|
||||
- logo:
|
||||
icon: /images/logo.jpg
|
||||
|
||||
- greeting:
|
||||
text_size: xl
|
||||
text: el-kadi ops
|
||||
|
||||
- datetime:
|
||||
text_size: xl
|
||||
format:
|
||||
timeStyle: short
|
||||
dateStyle: medium
|
||||
hour12: false
|
||||
|
||||
- search:
|
||||
provider: google
|
||||
target: _blank
|
||||
showSearchSuggestions: true
|
||||
|
||||
- resources:
|
||||
cpu: true
|
||||
memory: true
|
||||
cputemp: true
|
||||
uptime: true
|
||||
units: metric
|
||||
refresh: 3000
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
with open(HOMARR) as f:
|
||||
d = json.load(f)
|
||||
cats = {c["id"]: c["name"] for c in d.get("categories", [])}
|
||||
by_group: dict[str, list] = {g: [] for g in SERVICE_GROUPS}
|
||||
|
||||
for a in d.get("apps", []):
|
||||
name = (a.get("name") or "").strip()
|
||||
url = (a.get("url") or "").strip()
|
||||
if not name or not url:
|
||||
continue
|
||||
area = a.get("area", {}).get("properties", {}).get("id", "")
|
||||
homarr_cat = cats.get(area, "Other")
|
||||
group = classify_app(name, url, homarr_cat)
|
||||
by_group.setdefault(group, []).append((name, url))
|
||||
|
||||
extra = load_websites_extra()
|
||||
extra_count = sum(len(v) for v in extra.values())
|
||||
by_group = merge_groups(by_group, extra)
|
||||
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
(OUT / "services.yaml").write_text(write_services(by_group))
|
||||
(OUT / "settings.yaml").write_text(write_settings())
|
||||
(OUT / "widgets.yaml").write_text(write_widgets())
|
||||
(OUT / "bookmarks.yaml").write_text(write_bookmarks())
|
||||
|
||||
total = sum(len(v) for v in by_group.values())
|
||||
print(
|
||||
f"Generated {total} apps in {sum(1 for v in by_group.values() if v)} groups"
|
||||
+ (f" (+{extra_count} uit websites-add.txt)" if extra_count else "")
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user