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:
mo
2026-05-17 18:45:55 +02:00
parent 9f431ff97b
commit 43c4ed7a6d
27 changed files with 2851 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
FROM python:3.11-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
docker.io curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py .
COPY static ./static
ENV PYTHONUNBUFFERED=1
EXPOSE 3010
CMD ["python", "server.py"]
+3
View File
@@ -0,0 +1,3 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
pydantic==2.10.3
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Homepage Admin UI — sites toevoegen via browser."""
import os
import re
import subprocess
import threading
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
APP_ROOT = Path(os.environ.get("HOMEPAGE_DIR", Path(__file__).resolve().parent.parent))
WEBSITES_ADD = APP_ROOT / "websites-add.txt"
HOMELAB_APPS = APP_ROOT.parent
GENERATE = APP_ROOT / "generate-config.py"
DEPLOY = APP_ROOT / "deploy-to-pve.sh"
GROUPS = [
"Infrastructure",
"Media & TV",
"Smart Home",
"Productivity",
"Tools & Utils",
"AI Assistants",
"Dev & Docs",
"Web Design",
]
TAB_FOR_GROUP = {
"Infrastructure": "Ops",
"Media & TV": "Media",
"Smart Home": "Home",
"Productivity": "Work",
"Tools & Utils": "Ops",
"AI Assistants": "AI",
"Dev & Docs": "AI",
"Web Design": "AI",
}
_apply_lock = threading.Lock()
_apply_status = {"running": False, "ok": None, "log": ""}
class SiteIn(BaseModel):
group: str
name: str
url: str
class SiteOut(BaseModel):
id: int
group: str
name: str
url: str
tab: str
def read_sites() -> list[dict]:
if not WEBSITES_ADD.exists():
WEBSITES_ADD.write_text(
"# groep|naam|url\n# Of gebruik de UI op poort 3010\n\n",
encoding="utf-8",
)
rows = []
for i, raw in enumerate(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
g, n, u = parts
rows.append(
{
"id": len(rows),
"group": g,
"name": n,
"url": u,
"tab": TAB_FOR_GROUP.get(g, "?"),
}
)
return rows
def write_sites(rows: list[tuple[str, str, str]]) -> None:
lines = [
"# Extra websites — beheerd via Homepage Admin UI",
"# groep|naam|url",
"",
]
for g, n, u in rows:
n = n.replace("|", "-")
lines.append(f"{g}|{n}|{u}")
lines.append("")
WEBSITES_ADD.write_text("\n".join(lines), encoding="utf-8")
def run_apply():
global _apply_status
with _apply_lock:
_apply_status = {"running": True, "ok": None, "log": "Start…\n"}
try:
gen = subprocess.run(
["python3", str(GENERATE)],
cwd=str(APP_ROOT),
capture_output=True,
text=True,
timeout=120,
)
_apply_status["log"] += gen.stdout or ""
if gen.stderr:
_apply_status["log"] += gen.stderr
if gen.returncode != 0:
_apply_status["ok"] = False
_apply_status["log"] += "\nFout bij generate-config.py"
return
_apply_status["log"] += "\nDeploy naar Proxmox…\n"
dep = subprocess.run(
["/bin/sh", str(DEPLOY)],
cwd=str(APP_ROOT),
capture_output=True,
text=True,
timeout=300,
env={**os.environ},
)
_apply_status["log"] += dep.stdout or ""
if dep.stderr:
_apply_status["log"] += dep.stderr
_apply_status["ok"] = dep.returncode == 0
if _apply_status["ok"]:
_apply_status["log"] += "\n✓ Klaar — refresh Homepage (Ctrl+Shift+R)"
else:
_apply_status["log"] += "\n✗ Deploy mislukt"
except Exception as e:
_apply_status["ok"] = False
_apply_status["log"] += f"\nFout: {e}"
finally:
_apply_status["running"] = False
app = FastAPI(title="Homepage Admin")
static_dir = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
@app.get("/", response_class=HTMLResponse)
def index():
return FileResponse(static_dir / "index.html")
@app.get("/api/groups")
def api_groups():
return [{"name": g, "tab": TAB_FOR_GROUP.get(g, "")} for g in GROUPS]
@app.get("/api/sites")
def api_sites():
return read_sites()
@app.post("/api/sites")
def api_add(site: SiteIn):
if site.group not in GROUPS:
raise HTTPException(400, f"Onbekende groep: {site.group}")
name = site.name.strip()
url = site.url.strip()
if not name or not url:
raise HTTPException(400, "Naam en URL zijn verplicht")
if not re.match(r"^https?://", url, re.I):
raise HTTPException(400, "URL moet beginnen met http:// of https://")
rows = [(s["group"], s["name"], s["url"]) for s in read_sites()]
rows.append((site.group, name, url))
write_sites(rows)
return {"ok": True}
@app.delete("/api/sites/{site_id}")
def api_delete(site_id: int):
sites = read_sites()
if site_id < 0 or site_id >= len(sites):
raise HTTPException(404, "Site niet gevonden")
rows = [(s["group"], s["name"], s["url"]) for i, s in enumerate(sites) if i != site_id]
write_sites(rows)
return {"ok": True}
@app.post("/api/apply")
def api_apply():
if _apply_status.get("running"):
raise HTTPException(409, "Bezig met toepassen…")
t = threading.Thread(target=run_apply, daemon=True)
t.start()
return {"ok": True, "message": "Gestart"}
@app.get("/api/apply/status")
def api_apply_status():
return _apply_status
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=3010)
+260
View File
@@ -0,0 +1,260 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Homepage Admin — EL-KADI OPS</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
:root {
--void: #02060c;
--surface: #0a1424;
--border: rgba(56, 132, 220, 0.2);
--blue: #3b82f6;
--cyan: #22d3ee;
--orange: #fb923c;
--text: #e8eef7;
--muted: #64748b;
--mono: 'JetBrains Mono', monospace;
--sans: 'DM Sans', system-ui, sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--sans);
background: var(--void);
color: var(--text);
min-height: 100vh;
padding: 1.5rem;
}
body::before {
content: '';
position: fixed; inset: 0;
background:
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(59,130,246,.2), transparent 50%),
radial-gradient(ellipse 50% 40% at 90% 80%, rgba(251,146,60,.1), transparent 45%);
pointer-events: none; z-index: 0;
}
.wrap { max-width: 920px; margin: 0 auto; position: relative; z-index: 1; }
header {
display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between;
gap: 1rem; margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border);
}
h1 { font-size: 1.1rem; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; }
h1 span { color: var(--orange); font-family: var(--mono); font-size: 0.7rem; display: block; margin-top: 0.25rem; letter-spacing: 0.2em; }
.links a {
color: var(--cyan); text-decoration: none; font-size: 0.85rem; margin-left: 1rem;
}
.links a:hover { color: var(--orange); }
.card {
background: rgba(10, 22, 40, 0.92);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 1.25rem;
backdrop-filter: blur(12px);
}
.card h2 {
font-family: var(--mono); font-size: 0.65rem; letter-spacing: 0.2em;
text-transform: uppercase; color: var(--cyan); margin-bottom: 1rem;
}
label { display: block; font-size: 0.75rem; color: var(--muted); margin-bottom: 0.35rem; }
input, select {
width: 100%; padding: 0.65rem 0.75rem; margin-bottom: 0.85rem;
background: rgba(5, 12, 24, 0.8); border: 1px solid var(--border); border-radius: 8px;
color: var(--text); font-family: var(--sans); font-size: 0.95rem;
}
input:focus, select:focus { outline: none; border-color: var(--orange); }
.grid { display: grid; grid-template-columns: 1fr 2fr 2fr auto; gap: 0.75rem; align-items: end; }
@media (max-width: 700px) { .grid { grid-template-columns: 1fr; } }
.btn {
padding: 0.65rem 1.1rem; border: none; border-radius: 8px; cursor: pointer;
font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.08em;
text-transform: uppercase; transition: opacity 0.2s, transform 0.15s;
}
.btn:hover { transform: translateY(-1px); }
.btn-primary { background: linear-gradient(135deg, var(--blue), #2563eb); color: #fff; }
.btn-accent { background: linear-gradient(135deg, var(--orange), #ea580c); color: #fff; width: 100%; margin-top: 0.5rem; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--muted); padding: 0.4rem 0.6rem; }
.btn-ghost:hover { border-color: #f87171; color: #f87171; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
th {
text-align: left; font-family: var(--mono); font-size: 0.58rem;
letter-spacing: 0.15em; text-transform: uppercase; color: var(--muted);
padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--border);
}
td { padding: 0.65rem 0.4rem; border-bottom: 1px solid rgba(255,255,255,0.04); vertical-align: middle; }
td a { color: var(--cyan); text-decoration: none; word-break: break-all; }
td a:hover { color: var(--orange); }
.tag {
display: inline-block; font-family: var(--mono); font-size: 0.58rem;
padding: 0.15rem 0.45rem; border-radius: 4px;
background: rgba(59,130,246,0.15); color: var(--cyan); margin-right: 0.35rem;
}
.empty { color: var(--muted); font-style: italic; padding: 1rem 0; }
#log {
font-family: var(--mono); font-size: 0.7rem; line-height: 1.5;
background: rgba(0,0,0,0.35); border-radius: 8px; padding: 0.75rem;
max-height: 200px; overflow: auto; color: var(--muted); white-space: pre-wrap;
margin-top: 0.75rem; display: none;
}
#log.show { display: block; }
#log.ok { color: #4ade80; }
#log.err { color: #f87171; }
.toast {
position: fixed; bottom: 1.5rem; right: 1.5rem; padding: 0.75rem 1rem;
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
font-size: 0.85rem; opacity: 0; transition: opacity 0.3s; z-index: 10;
}
.toast.show { opacity: 1; }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Homepage Admin<span>Websites toevoegen</span></h1>
<div class="links">
<a href="http://192.168.1.192:3000" target="_blank" rel="noreferrer">→ Open dashboard</a>
</div>
</header>
<section class="card">
<h2>Nieuwe website</h2>
<form id="add-form">
<div class="grid">
<div>
<label for="group">Groep / tab</label>
<select id="group" required></select>
</div>
<div>
<label for="name">Naam</label>
<input id="name" type="text" placeholder="bijv. Jellyfin" required />
</div>
<div>
<label for="url">URL</label>
<input id="url" type="url" placeholder="http://192.168.1.10:8096" required />
</div>
<div>
<button type="submit" class="btn btn-primary">Toevoegen</button>
</div>
</div>
</form>
</section>
<section class="card">
<h2>Jouw extra sites</h2>
<div id="list-wrap">
<p class="empty">Laden…</p>
</div>
</section>
<section class="card">
<h2>Dashboard bijwerken</h2>
<p style="font-size:0.85rem;color:var(--muted);margin-bottom:0.75rem;">
Na toevoegen of verwijderen: klik hieronder. Dit genereert de config en deployt naar Proxmox (±1 min).
</p>
<button type="button" id="apply-btn" class="btn btn-accent">Toepassen op Homepage</button>
<pre id="log"></pre>
</section>
</div>
<div id="toast" class="toast"></div>
<script>
const API = '';
const $ = (s) => document.querySelector(s);
function toast(msg) {
const t = $('#toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2800);
}
async function loadGroups() {
const groups = await fetch(API + '/api/groups').then((r) => r.json());
$('#group').innerHTML = groups
.map((g) => `<option value="${g.name}">${g.name} (tab ${g.tab})</option>`)
.join('');
}
async function loadSites() {
const sites = await fetch(API + '/api/sites').then((r) => r.json());
const wrap = $('#list-wrap');
if (!sites.length) {
wrap.innerHTML = '<p class="empty">Nog geen extra sites — voeg hierboven toe.</p>';
return;
}
wrap.innerHTML = `<table>
<thead><tr><th>Groep</th><th>Naam</th><th>URL</th><th></th></tr></thead>
<tbody>${sites.map((s) => `
<tr>
<td><span class="tag">${s.tab}</span>${esc(s.group)}</td>
<td>${esc(s.name)}</td>
<td><a href="${esc(s.url)}" target="_blank" rel="noreferrer">${esc(s.url)}</a></td>
<td><button type="button" class="btn btn-ghost" data-id="${s.id}">Verwijder</button></td>
</tr>`).join('')}</tbody>
</table>`;
wrap.querySelectorAll('[data-id]').forEach((btn) => {
btn.onclick = async () => {
if (!confirm('Verwijderen?')) return;
await fetch(API + '/api/sites/' + btn.dataset.id, { method: 'DELETE' });
toast('Verwijderd — klik nog op Toepassen');
loadSites();
};
});
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
$('#add-form').onsubmit = async (e) => {
e.preventDefault();
const body = {
group: $('#group').value,
name: $('#name').value.trim(),
url: $('#url').value.trim(),
};
const r = await fetch(API + '/api/sites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
toast(err.detail || 'Fout bij toevoegen');
return;
}
$('#name').value = '';
$('#url').value = '';
toast('Toegevoegd — klik op Toepassen');
loadSites();
};
$('#apply-btn').onclick = async () => {
const btn = $('#apply-btn');
const log = $('#log');
btn.disabled = true;
log.className = 'show';
log.textContent = 'Bezig…\n';
await fetch(API + '/api/apply', { method: 'POST' });
const poll = setInterval(async () => {
const st = await fetch(API + '/api/apply/status').then((r) => r.json());
log.textContent = st.log || '…';
if (!st.running) {
clearInterval(poll);
btn.disabled = false;
log.classList.add(st.ok ? 'ok' : 'err');
toast(st.ok ? 'Homepage bijgewerkt!' : 'Fout — zie log');
}
}, 1500);
};
loadGroups().then(loadSites);
</script>
</body>
</html>