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
+1
View File
@@ -13,6 +13,7 @@ Private repo. Laatst bijgewerkt vanaf NAS `192.168.1.211`.
| DuckDNS | [apps/duckdns](apps/duckdns/) | — | running | | DuckDNS | [apps/duckdns](apps/duckdns/) | — | running |
| Neo4j | [apps/neo4j](apps/neo4j/) | :4915349155 | running | | Neo4j | [apps/neo4j](apps/neo4j/) | :4915349155 | running |
| Homarr | [apps/homarr](apps/homarr/) | :4755 | running | | Homarr | [apps/homarr](apps/homarr/) | :4755 | running |
| Homepage | [apps/homepage](apps/homepage/) | http://192.168.1.192:3000 (pve CT 120) | running |
| Portainer | [apps/portainer](apps/portainer/) | :9000 | running | | Portainer | [apps/portainer](apps/portainer/) | :9000 | running |
| Remotely | [apps/remotely](apps/remotely/) | :8080 | running | | Remotely | [apps/remotely](apps/remotely/) | :8080 | running |
| Excalidraw | [apps/excalidraw](apps/excalidraw/) | :3765 | running | | Excalidraw | [apps/excalidraw](apps/excalidraw/) | :3765 | running |
+89
View File
@@ -0,0 +1,89 @@
# Homepage (gethomepage.dev)
Palantir-stijl homelab-dashboard.
| | |
|---|---|
| **URL** | http://192.168.1.192:3000 |
| **Proxmox** | pve CT 120 (`192.168.1.216`) |
## Grafisch toevoegen (Admin UI)
Open in je browser:
**http://192.168.1.211:3010**
Eerste keer starten op de NAS:
```bash
cd /volume1/docker/homelab-configs/apps/homepage
./start-admin.sh
```
1. Vul naam + URL in → **Toevoegen**
2. Klik **Toepassen op Homepage** (deploy naar Proxmox)
3. Refresh http://192.168.1.192:3000
---
## Snel een website toevoegen (terminal)
**Niet** in `services.yaml` zoeken — gebruik **`websites-add.txt`** of het script:
### Optie A — interactief (makkelijkst)
```bash
cd /volume1/docker/homelab-configs/apps/homepage
./add-website.sh
```
### Optie B — één commando
```bash
./add-website.sh "Jellyfin" "http://192.168.1.10:8096" "Media & TV"
```
### Optie C — regel toevoegen in `websites-add.txt`
```
Productivity|Mijn app|http://192.168.1.150:8080
AI Assistants|Claude|https://claude.ai
```
Daarna: `./apply.sh` (genereert config + deploy)
**Groepen:** `Infrastructure`, `Media & TV`, `Smart Home`, `Productivity`, `Tools & Utils`, `AI Assistants`, `Dev & Docs`, `Web Design`
## Tabs
| Tab | Inhoud |
|-----|--------|
| **Ops** | Infrastructure, Tools & Utils |
| **Media** | Media & TV |
| **Home** | Smart Home |
| **Work** | Productivity |
| **AI** | AI Assistants, Dev & Docs, Web Design |
| **Feeds** | RSS + bookmarks |
## Overige commando's
```bash
./set-logo.sh /pad/naar/foto.jpg # profielfoto
python3 generate-config.py # Homarr + websites-add.txt → config/
./deploy-to-pve.sh # alleen deploy
./apply.sh # generate + deploy
```
## Config-bestanden
| Bestand | Wie bewerkt |
|---------|-------------|
| **`websites-add.txt`** | **Jij** — extra links (1 regel per site) |
| `config/services.yaml` | Automatisch gegenereerd |
| `config/settings.yaml` | Tabs, layout |
| `config/widgets.yaml` | Logo, klok, zoeken |
| `config/custom.css` | Thema |
Homarr-sync: wijzigingen in Homarr → `python3 generate-config.py``./apply.sh`
Docs: https://gethomepage.dev/
+59
View File
@@ -0,0 +1,59 @@
#!/bin/sh
# Voeg snel een website toe: ./add-website.sh of ./add-website.sh "Naam" "url" "groep"
set -e
APP="$(cd "$(dirname "$0")" && pwd)"
FILE="$APP/websites-add.txt"
usage() {
echo "Gebruik:"
echo " $0"
echo " $0 \"Naam\" \"http://url\" [groep]"
echo ""
echo "Groepen: Infrastructure | \"Media & TV\" | \"Smart Home\" | Productivity"
echo " \"Tools & Utils\" | \"AI Assistants\" | \"Dev & Docs\" | \"Web Design\""
exit 1
}
pick_group() {
echo "Kies groep:"
echo " 1) Infrastructure 5) Tools & Utils"
echo " 2) Media & TV 6) AI Assistants"
echo " 3) Smart Home 7) Dev & Docs"
echo " 4) Productivity 8) Web Design"
printf "> "
read -r n
case "$n" in
1) echo "Infrastructure" ;;
2) echo "Media & TV" ;;
3) echo "Smart Home" ;;
4) echo "Productivity" ;;
5) echo "Tools & Utils" ;;
6) echo "AI Assistants" ;;
7) echo "Dev & Docs" ;;
8) echo "Web Design" ;;
*) echo "Productivity" ;;
esac
}
if [ $# -eq 0 ]; then
printf "Naam: "
read -r NAME
printf "URL: "
read -r URL
GROUP=$(pick_group)
elif [ $# -ge 2 ]; then
NAME="$1"
URL="$2"
GROUP="${3:-Productivity}"
else
usage
fi
[ -n "$NAME" ] && [ -n "$URL" ] || usage
# Pipe in naam/url escapen
NAME_ESC=$(printf '%s' "$NAME" | sed 's/|/-/g')
echo "${GROUP}|${NAME_ESC}|${URL}" >> "$FILE"
echo "Toegevoegd: ${GROUP} | ${NAME} | ${URL}"
echo ""
"$APP/apply.sh"
+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>
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Genereer config + deploy naar Proxmox (na wijziging websites.yaml of Homarr)
set -e
APP="$(cd "$(dirname "$0")" && pwd)"
cd "$APP"
echo "=== Config genereren (Homarr + websites.yaml) ==="
python3 generate-config.py
echo "=== Deploy naar CT 120 ==="
./deploy-to-pve.sh
+60
View File
@@ -0,0 +1,60 @@
---
- Tech News:
- Hacker News:
- abbr: HN
href: https://hnrss.org/frontpage
- Ars Technica:
- abbr: AT
href: https://feeds.arstechnica.com/arstechnica/index
- The Verge:
- abbr: TV
href: https://www.theverge.com/rss/index.xml
- Lobsters:
- abbr: L
href: https://lobste.rs/rss
- Security:
- BleepingComputer:
- abbr: B
href: https://www.bleepingcomputer.com/feed/
- Krebs on Security:
- abbr: KO
href: https://krebsonsecurity.com/feed/
- The Hacker News:
- abbr: TH
href: https://feeds.feedburner.com/TheHackersNews
- Homelab:
- selfh.st:
- abbr: SS
href: https://selfh.st/rss/
- Proxmox Forum:
- abbr: PF
href: https://forum.proxmox.com/forums/-/index.rss
- r/selfhosted:
- abbr: RS
href: https://www.reddit.com/r/selfhosted/.rss
- Docker Blog:
- abbr: DB
href: https://www.docker.com/blog/feed/
- LinuxServer.io:
- abbr: LI
href: https://www.linuxserver.io/blog/rss/
- Marius Hosting:
- abbr: MH
href: https://mariushosting.com/feed/
- AI & Dev:
- OpenAI Blog:
- abbr: OB
href: https://openai.com/blog/rss.xml
- Hugging Face Blog:
- abbr: HF
href: https://huggingface.co/blog/feed.xml
- GitHub Blog:
- abbr: GB
href: https://github.blog/feed/
- Blogs:
- Jack van lightly:
- abbr: JV
href: https://jack-vanlightly.com/feed.xml
- Juhache:
- abbr: J
href: https://juhache.substack.com/feed
+431
View File
@@ -0,0 +1,431 @@
/* EL-KADI OPS — Palantir blue + orange */
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap');
:root {
--p-void: #02060c;
--p-deep: #051018;
--p-surface: #0a1424;
--p-elevated: rgba(10, 22, 40, 0.92);
--p-border: rgba(56, 132, 220, 0.18);
--p-border-hot: rgba(251, 146, 60, 0.45);
--p-blue: #3b82f6;
--p-cyan: #22d3ee;
--p-orange: #fb923c;
--p-amber: #fbbf24;
--p-teal: #2dd4bf;
--p-text: #e8eef7;
--p-muted: #64748b;
--p-mono: 'JetBrains Mono', ui-monospace, monospace;
--p-sans: 'DM Sans', system-ui, sans-serif;
}
html { color-scheme: dark; }
body {
font-family: var(--p-sans) !important;
background: var(--p-void) !important;
color: var(--p-text) !important;
}
body::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(ellipse 100% 70% at 8% -15%, rgba(59, 130, 246, 0.22), transparent 52%),
radial-gradient(ellipse 80% 55% at 95% 5%, rgba(34, 211, 238, 0.12), transparent 48%),
radial-gradient(ellipse 60% 50% at 88% 75%, rgba(251, 146, 60, 0.14), transparent 45%),
radial-gradient(ellipse 50% 40% at 12% 85%, rgba(251, 146, 60, 0.08), transparent 40%),
linear-gradient(165deg, #061018 0%, #02060c 45%, #030810 100%);
pointer-events: none;
z-index: 0;
animation: bg-breathe 14s ease-in-out infinite alternate;
}
@keyframes bg-breathe {
0% { opacity: 1; }
100% { opacity: 0.9; }
}
body::after {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(59, 130, 246, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(59, 130, 246, 0.04) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, black 10%, transparent 72%);
pointer-events: none;
z-index: 0;
animation: grid-drift 40s linear infinite;
}
@keyframes grid-drift {
from { transform: translate(0, 0); }
to { transform: translate(-56px, -56px); }
}
.palantir-scanline {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1;
background: repeating-linear-gradient(0deg, transparent, transparent 3px, rgba(0,0,0,0.04) 3px, rgba(0,0,0,0.04) 6px);
opacity: 0.35;
}
.palantir-noise {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
#page_wrapper { position: relative; z-index: 2; }
#information-widget-greeting, .information-widget-greeting {
font-weight: 600 !important;
letter-spacing: 0.22em !important;
text-transform: uppercase !important;
font-size: 0.7rem !important;
color: var(--p-orange) !important;
font-family: var(--p-mono) !important;
}
.information-widget {
border: 1px solid var(--p-border) !important;
border-radius: 10px !important;
background: var(--p-elevated) !important;
backdrop-filter: blur(14px);
box-shadow: 0 4px 24px rgba(0,0,0,0.35), inset 0 1px 0 rgba(59,130,246,0.08);
}
button[role="tab"] {
font-family: var(--p-mono) !important;
font-size: 0.68rem !important;
letter-spacing: 0.14em !important;
text-transform: uppercase !important;
border: 1px solid var(--p-border) !important;
border-radius: 6px !important;
transition: all 0.25s ease;
}
button[role="tab"][aria-selected="true"] {
background: linear-gradient(135deg, rgba(59,130,246,0.2), rgba(251,146,60,0.12)) !important;
border-color: var(--p-border-hot) !important;
color: var(--p-cyan) !important;
box-shadow: 0 0 20px rgba(251,146,60,0.15);
}
.service-block, #services .service-group .service {
border: 1px solid var(--p-border) !important;
border-radius: 10px !important;
background: var(--p-elevated) !important;
backdrop-filter: blur(12px);
transition: border-color 0.2s, box-shadow 0.25s, transform 0.2s;
}
.service-block:hover, #services .service-group .service:hover {
border-color: rgba(251,146,60,0.5) !important;
box-shadow: 0 0 28px rgba(59,130,246,0.12), 0 0 12px rgba(251,146,60,0.1);
transform: translateY(-1px);
}
#services h2.service-group-name, .service-group-title {
font-family: var(--p-mono) !important;
font-size: 0.62rem !important;
letter-spacing: 0.2em !important;
text-transform: uppercase !important;
color: var(--p-cyan) !important;
}
#bookmarks .bookmark-group h2, .bookmark-group-name {
font-family: var(--p-mono) !important;
font-size: 0.62rem !important;
letter-spacing: 0.18em !important;
text-transform: uppercase !important;
color: var(--p-orange) !important;
}
#bookmarks a {
border: 1px solid var(--p-border) !important;
transition: border-color 0.2s, box-shadow 0.2s;
}
#bookmarks a:hover {
border-color: rgba(251,146,60,0.4) !important;
box-shadow: 0 0 16px rgba(59,130,246,0.1);
}
.rss-hub {
margin: 1rem 1.5rem 1.5rem;
padding: 1.25rem 1.5rem;
border: 1px solid var(--p-border);
border-radius: 14px;
background: linear-gradient(145deg, rgba(10,22,40,0.95), rgba(6,12,22,0.98));
box-shadow: 0 8px 40px rgba(0,0,0,0.45), inset 0 1px 0 rgba(59,130,246,0.12);
backdrop-filter: blur(16px);
position: relative;
overflow: hidden;
}
.rss-hub::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--p-blue), var(--p-orange), var(--p-cyan), transparent);
animation: rss-scan 4s ease-in-out infinite;
}
@keyframes rss-scan {
0%, 100% { opacity: 0.5; transform: scaleX(0.6); }
50% { opacity: 1; transform: scaleX(1); }
}
.rss-hub-title {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.5rem 1rem;
margin-bottom: 1.25rem;
font-family: var(--p-mono);
font-size: 0.72rem;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--p-cyan);
}
.rss-hub-accent {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
background: var(--p-orange);
box-shadow: 0 0 12px var(--p-orange);
animation: pulse-dot 2s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(0.85); }
}
.rss-hub-sub {
font-size: 0.58rem;
letter-spacing: 0.1em;
color: var(--p-muted);
text-transform: none;
}
.rss-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
@media (max-width: 1280px) { .rss-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 640px) { .rss-grid { grid-template-columns: 1fr; } }
.rss-column {
border: 1px solid rgba(59,130,246,0.12);
border-radius: 10px;
padding: 0.75rem;
background: rgba(5,12,24,0.6);
min-height: 160px;
}
.rss-column-head {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: var(--p-mono);
font-size: 0.58rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--p-orange);
margin-bottom: 0.65rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--p-border);
}
.rss-pulse {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--p-cyan);
animation: pulse-dot 1.8s ease-in-out infinite;
}
.rss-feed-block { list-style: none; margin-bottom: 0.85rem; }
.rss-feed-name {
display: block;
font-family: var(--p-mono);
font-size: 0.55rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--p-muted);
margin-bottom: 0.35rem;
}
.rss-items { list-style: none; padding: 0; margin: 0; }
.rss-items li {
display: flex;
justify-content: space-between;
gap: 0.5rem;
padding: 0.28rem 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
font-size: 0.72rem;
line-height: 1.35;
animation: rss-fade-in 0.4s ease forwards;
opacity: 0;
}
.rss-items li:nth-child(1) { animation-delay: 0.05s; }
.rss-items li:nth-child(2) { animation-delay: 0.1s; }
.rss-items li:nth-child(3) { animation-delay: 0.15s; }
.rss-items li:nth-child(4) { animation-delay: 0.2s; }
.rss-items li:nth-child(5) { animation-delay: 0.25s; }
@keyframes rss-fade-in { to { opacity: 1; } }
.rss-items a {
color: var(--p-text) !important;
text-decoration: none;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rss-items a:hover { color: var(--p-orange) !important; }
.rss-items time {
font-family: var(--p-mono);
font-size: 0.58rem;
color: var(--p-muted);
flex-shrink: 0;
}
.rss-loading { color: var(--p-muted); font-style: italic; }
.rss-error a { color: var(--p-orange) !important; }
.status-dot-status-green, .text-green-500 {
box-shadow: 0 0 8px rgba(74, 222, 128, 0.55);
}
footer, #footer {
opacity: 0.35;
font-family: var(--p-mono);
font-size: 0.62rem;
}
/* ─── Logo gradients + profile photo ─── */
:root {
--color-logo-start: 59, 130, 246;
--color-logo-stop: 251, 146, 60;
}
.information-widget-logo img,
.information-widget-logo a img,
#information-widgets .information-widget-logo img {
width: 52px !important;
height: 52px !important;
min-width: 52px !important;
min-height: 52px !important;
border-radius: 50% !important;
object-fit: cover !important;
border: 2px solid rgba(251, 146, 60, 0.55) !important;
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.35), 0 4px 16px rgba(0, 0, 0, 0.4) !important;
filter: none !important;
animation: none !important;
}
.information-widget-logo:hover img {
border-color: rgba(34, 211, 238, 0.7) !important;
}
.service-icon {
position: relative !important;
border-radius: 8px !important;
margin: 2px 4px 2px 2px !important;
background: rgba(12, 22, 38, 0.85) !important;
border: 1px solid rgba(59, 130, 246, 0.12) !important;
box-shadow: none !important;
animation: none !important;
overflow: hidden;
}
.service-icon::before,
.service-icon::after {
display: none !important;
content: none !important;
}
.service-icon > div { filter: none !important; transition: transform 0.2s ease; }
.service-icon img { filter: saturate(1.12) contrast(1.02) !important; transition: transform 0.2s ease; }
.service-card:hover .service-icon {
border-color: rgba(251, 146, 60, 0.35) !important;
box-shadow: 0 0 0 1px rgba(251, 146, 60, 0.15) !important;
}
.service-card:hover .service-icon > div,
.service-card:hover .service-icon img { transform: scale(1.06); }
.services-list .service:nth-child(6n+1) .service-icon { border-left: 2px solid #3b82f6 !important; }
.services-list .service:nth-child(6n+2) .service-icon { border-left: 2px solid #a855f7 !important; }
.services-list .service:nth-child(6n+3) .service-icon { border-left: 2px solid #22c55e !important; }
.services-list .service:nth-child(6n+4) .service-icon { border-left: 2px solid #06b6d4 !important; }
.services-list .service:nth-child(6n+5) .service-icon { border-left: 2px solid #f59e0b !important; }
.services-list .service:nth-child(6n+6) .service-icon { border-left: 2px solid #ec4899 !important; }
.bookmark-icon { font-weight: 600 !important; color: #fff !important; animation: none !important; box-shadow: none !important; }
.bookmark:nth-child(4n+1) .bookmark-icon { background: linear-gradient(135deg, #2563eb, #3b82f6) !important; }
.bookmark:nth-child(4n+2) .bookmark-icon { background: linear-gradient(135deg, #ea580c, #fb923c) !important; }
.bookmark:nth-child(4n+3) .bookmark-icon { background: linear-gradient(135deg, #7c3aed, #a855f7) !important; }
.bookmark:nth-child(4n+4) .bookmark-icon { background: linear-gradient(135deg, #059669, #22c55e) !important; }
.information-widget-resource .resource-icon { animation: none !important; filter: none !important; }
.information-widget-resource:nth-child(1) .resource-icon { color: #60a5fa !important; }
.information-widget-resource:nth-child(2) .resource-icon { color: #fb923c !important; }
.information-widget-resource:nth-child(3) .resource-icon { color: #4ade80 !important; }
.resource-usage > div {
background: linear-gradient(90deg, #3b82f6, #fb923c) !important;
animation: none !important;
background-size: 100% 100% !important;
}
.status-dot-status-green, .text-green-500 {
background-color: #4ade80 !important;
animation: status-pulse-green 3s ease-out infinite;
}
.status-dot-status-red, .text-red-500 {
background-color: #f87171 !important;
animation: status-pulse-red 3s ease-out infinite;
}
@keyframes status-pulse-green {
0% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.5); }
70% { box-shadow: 0 0 0 6px rgba(74, 222, 128, 0); }
}
@keyframes status-pulse-red {
0% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.5); }
70% { box-shadow: 0 0 0 6px rgba(248, 113, 113, 0); }
}
button[role="tab"][aria-selected="true"] { animation: none !important; }
.service-card { animation: none !important; }
.rss-hub { animation: none !important; }
@media (prefers-reduced-motion: reduce) {
body::before, body::after, .rss-hub::before, .rss-pulse, .rss-hub-accent,
.service-icon, .service-icon::before, .service-icon::after, .service-card,
.bookmark-icon, .information-widget-resource .resource-icon, button[role="tab"][aria-selected="true"],
.rss-hub, #information-widgets img { animation: none !important; }
}
+143
View File
@@ -0,0 +1,143 @@
/* EL-KADI OPS — live RSS panels (Palantir feed hub) */
(function () {
const FEEDS = {
"Tech News": [
{ name: "Hacker News", url: "https://hnrss.org/frontpage" },
{ name: "Ars Technica", url: "https://feeds.arstechnica.com/arstechnica/index" },
{ name: "The Verge", url: "https://www.theverge.com/rss/index.xml" },
{ name: "Lobsters", url: "https://lobste.rs/rss" },
],
Security: [
{ name: "BleepingComputer", url: "https://www.bleepingcomputer.com/feed/" },
{ name: "Krebs", url: "https://krebsonsecurity.com/feed/" },
{ name: "The Hacker News", url: "https://feeds.feedburner.com/TheHackersNews" },
],
Homelab: [
{ name: "selfh.st", url: "https://selfh.st/rss/" },
{ name: "Proxmox Forum", url: "https://forum.proxmox.com/forums/-/index.rss" },
{ name: "r/selfhosted", url: "https://www.reddit.com/r/selfhosted/.rss" },
{ name: "Docker Blog", url: "https://www.docker.com/blog/feed/" },
{ name: "LinuxServer.io", url: "https://www.linuxserver.io/blog/rss/" },
],
"AI & Dev": [
{ name: "OpenAI Blog", url: "https://openai.com/blog/rss.xml" },
{ name: "Hugging Face", url: "https://huggingface.co/blog/feed.xml" },
{ name: "GitHub Blog", url: "https://github.blog/feed/" },
],
};
const PROXY = "https://api.allorigins.win/raw?url=";
const REFRESH_MS = 15 * 60 * 1000;
function esc(s) {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
function relTime(pub) {
if (!pub) return "";
const t = new Date(pub).getTime();
if (Number.isNaN(t)) return "";
const m = Math.floor((Date.now() - t) / 60000);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 48) return `${h}h`;
return `${Math.floor(h / 24)}d`;
}
async function fetchFeed(feedUrl) {
const res = await fetch(PROXY + encodeURIComponent(feedUrl), { cache: "no-store" });
if (!res.ok) throw new Error("fetch failed");
const xml = new DOMParser().parseFromString(await res.text(), "text/xml");
const items = xml.querySelectorAll("item, entry");
return Array.from(items)
.slice(0, 6)
.map((item) => {
const title =
item.querySelector("title")?.textContent?.trim() ||
item.querySelector("title")?.innerHTML?.trim() ||
"—";
const link =
item.querySelector("link")?.getAttribute("href") ||
item.querySelector("link")?.textContent?.trim() ||
item.querySelector("id")?.textContent?.trim() ||
"#";
const pub =
item.querySelector("pubDate")?.textContent ||
item.querySelector("published")?.textContent ||
item.querySelector("updated")?.textContent;
return { title, link, pub };
});
}
function renderColumn(cat, feeds, root) {
const col = document.createElement("section");
col.className = "rss-column";
col.innerHTML = `<header class="rss-column-head"><span class="rss-pulse"></span>${esc(cat)}</header><ul class="rss-list"></ul>`;
const list = col.querySelector(".rss-list");
root.appendChild(col);
feeds.forEach((feed) => {
const block = document.createElement("li");
block.className = "rss-feed-block";
block.innerHTML = `<span class="rss-feed-name">${esc(feed.name)}</span><ul class="rss-items"><li class="rss-loading">Laden…</li></ul>`;
list.appendChild(block);
const itemsUl = block.querySelector(".rss-items");
fetchFeed(feed.url)
.then((items) => {
itemsUl.innerHTML = items.length
? items
.map(
(it) =>
`<li><a href="${esc(it.link)}" target="_blank" rel="noreferrer">${esc(it.title)}</a><time>${relTime(it.pub)}</time></li>`
)
.join("")
: '<li class="rss-empty">Geen items</li>';
})
.catch(() => {
itemsUl.innerHTML = `<li class="rss-error"><a href="${esc(feed.url)}" target="_blank" rel="noreferrer">Open feed →</a></li>`;
});
});
}
function injectHub() {
if (document.getElementById("elkadi-rss-hub")) return;
const scan = document.createElement("div");
scan.className = "palantir-scanline";
const noise = document.createElement("div");
noise.className = "palantir-noise";
document.body.appendChild(scan);
document.body.appendChild(noise);
const hub = document.createElement("div");
hub.id = "elkadi-rss-hub";
hub.className = "rss-hub";
hub.innerHTML =
'<div class="rss-hub-title"><span class="rss-hub-accent"></span>INTEL FEEDS<span class="rss-hub-sub">Live RSS · vernieuwt elke 15 min</span></div><div class="rss-grid"></div>';
const grid = hub.querySelector(".rss-grid");
Object.entries(FEEDS).forEach(([cat, feeds]) => renderColumn(cat, feeds, grid));
const services = document.getElementById("services");
if (services && services.parentNode) {
services.parentNode.insertBefore(hub, services);
} else {
document.body.prepend(hub);
}
setInterval(() => {
hub.querySelectorAll(".rss-column").forEach((c) => c.remove());
const g = hub.querySelector(".rss-grid");
Object.entries(FEEDS).forEach(([cat, feeds]) => renderColumn(cat, feeds, g));
}, REFRESH_MS);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => setTimeout(injectHub, 400));
} else {
setTimeout(injectHub, 400);
}
})();
+505
View File
@@ -0,0 +1,505 @@
- Infrastructure:
- Change detection:
icon: mdi-server-network-#3b82f6
href: http://192.168.1.117:5000
description: Change detection
siteMonitor: http://192.168.1.117:5000
statusStyle: dot
- Proxmox (3090):
icon: proxmox.png
href: https://192.168.1.216:8006
description: Proxmox (3090)
siteMonitor: https://192.168.1.216:8006
statusStyle: dot
- Proxmox (Dell):
icon: proxmox.png
href: https://192.168.1.56:8006
description: Proxmox (Dell)
siteMonitor: https://192.168.1.56:8006
statusStyle: dot
- Minarca:
icon: mdi-server-network-#3b82f6
href: http://192.168.1.203:8080/login
description: Minarca
siteMonitor: http://192.168.1.203:8080
statusStyle: dot
- Proxmox:
icon: proxmox.png
href: http://192.168.1.230:8006
description: Proxmox
siteMonitor: http://192.168.1.230:8006
statusStyle: dot
- Portainer (proxmox):
icon: portainer.png
href: https://192.168.5.128:9443/#!/home
description: Portainer (proxmox)
siteMonitor: https://192.168.5.128:9443
statusStyle: dot
- Rackula:
icon: mdi-count-#3b82f6
href: https://count.racku.la
description: Rackula
- TrueNAS:
icon: truenas.png
href: http://192.168.1.185
description: TrueNAS
siteMonitor: http://192.168.1.185
statusStyle: dot
- Adguard:
icon: adguard-home.png
href: http://192.168.1.211:3001
description: Adguard
siteMonitor: http://192.168.1.211:3001
statusStyle: dot
- Browserless:
icon: mdi-server-network-#3b82f6
href: http://192.168.1.150:3000
description: Browserless
siteMonitor: http://192.168.1.150:3000
statusStyle: dot
- Unifi:
icon: unifi.png
href: https://192.168.1.24
description: Unifi
siteMonitor: https://192.168.1.24
statusStyle: dot
- Wazuh:
icon: wazuh.png
href: https://192.168.1.73
description: Wazuh
siteMonitor: https://192.168.1.73
statusStyle: dot
- Modem:
icon: mdi-server-network-#3b82f6
href: https://192.168.10.1
description: Modem
siteMonitor: https://192.168.10.1
statusStyle: dot
- Nginx:
icon: nginx-proxy-manager.png
href: http://192.168.1.173:81
description: Nginx
siteMonitor: http://192.168.1.173:81
statusStyle: dot
- Webmin:
icon: mdi-server-network-#3b82f6
href: https://192.168.5.24:10000
description: Webmin
siteMonitor: https://192.168.5.24:10000
statusStyle: dot
- PVE Scripts:
icon: mdi-server-network-#3b82f6
href: http://192.168.1.25:3000
description: PVE Scripts
siteMonitor: http://192.168.1.25:3000
statusStyle: dot
- iDrac:
icon: mdi-server-network-#3b82f6
href: http://192.168.1.227
description: iDrac
siteMonitor: http://192.168.1.227
statusStyle: dot
- Remotely:
icon: remotely.png
href: http://192.168.1.211:8080
description: Remotely
siteMonitor: http://192.168.1.211:8080
statusStyle: dot
- mo-nas:
icon: mdi-server-network-#3b82f6
href: http://192.168.1.150:5000
description: mo-nas
siteMonitor: http://192.168.1.150:5000
statusStyle: dot
- NAS:
icon: mdi-server-network-#3b82f6
href: http://192.168.1.211:5000
description: NAS
siteMonitor: http://192.168.1.211:5000
statusStyle: dot
- Portainer:
icon: portainer.png
href: http://192.168.1.150:9000
description: Portainer
siteMonitor: http://192.168.1.150:9000
statusStyle: dot
- Portainer:
icon: portainer.png
href: http://192.168.1.211:9000
description: Portainer
siteMonitor: http://192.168.1.211:9000
statusStyle: dot
- Media & TV:
- nodecast:
icon: mdi-server-network-#a855f7
href: http://192.168.1.107:3000/#home
description: nodecast
siteMonitor: http://192.168.1.107:3000
statusStyle: dot
- Frigate:
icon: frigate.png
href: https://192.168.1.185:30058
description: Frigate
siteMonitor: https://192.168.1.185:30058
statusStyle: dot
- Tunarr:
icon: tunarr.png
href: http://192.168.1.237:8000
description: Tunarr
siteMonitor: http://192.168.1.237:8000
statusStyle: dot
- Immich:
icon: immich.png
href: http://192.168.1.17:2283
description: Immich
siteMonitor: http://192.168.1.17:2283
statusStyle: dot
- Metube:
icon: metube.png
href: http://192.168.1.140:8081
description: Metube
siteMonitor: http://192.168.1.140:8081
statusStyle: dot
- Smart Home:
- Mawaqit:
icon: mdi-mawaqit-#22c55e
href: https://mawaqit.net/nl/el-kadi-amsterdam-1061dj-netherlands
description: Mawaqit
- HA:
icon: mdi-server-network-#22c55e
href: http://192.168.1.235:8123
description: HA
siteMonitor: http://192.168.1.235:8123
statusStyle: dot
- Ring:
icon: mdi-account-#22c55e
href: https://account.ring.com/account/dashboard?l=24b26010-2cac-48f2-b8ec-e1ef4b5fa79e
description: Ring
- HA Voice Ctrl:
icon: home-assistant.png
href: http://192.168.1.211:8765
description: HA Voice Ctrl
siteMonitor: http://192.168.1.211:8765
statusStyle: dot
- Productivity:
- Resume Builder:
icon: mdi-server-network-#06b6d4
href: http://192.168.1.150:9751
description: Resume Builder
siteMonitor: http://192.168.1.150:9751
statusStyle: dot
- openresume:
icon: mdi-server-network-#06b6d4
href: http://192.168.1.150:6155
description: openresume
siteMonitor: http://192.168.1.150:6155
statusStyle: dot
- nextcloud:
icon: nextcloud.png
href: https://cloud.el-kadi.nl/index.php/apps/dashboard
description: nextcloud
- Invoice Generator:
icon: mdi-server-network-#06b6d4
href: http://192.168.1.150:3099
description: Invoice Generator
siteMonitor: http://192.168.1.150:3099
statusStyle: dot
- Vert:
icon: mdi-server-network-#06b6d4
href: http://192.168.1.150:3884
description: Vert
siteMonitor: http://192.168.1.150:3884
statusStyle: dot
- Vaultwarden:
icon: vaultwarden.png
href: https://192.168.1.6:8000/#/vault
description: Vaultwarden
siteMonitor: https://192.168.1.6:8000
statusStyle: dot
- Moocup:
icon: mdi-server-network-#06b6d4
href: http://192.168.1.150:3591
description: Moocup
siteMonitor: http://192.168.1.150:3591
statusStyle: dot
- n8n:
icon: n8n.png
href: http://192.168.1.64:5678/home/workflows
description: n8n
siteMonitor: http://192.168.1.64:5678
statusStyle: dot
- Linkwarden:
icon: linkwarden.png
href: http://192.168.1.150:7461/dashboard
description: Linkwarden
siteMonitor: http://192.168.1.150:7461
statusStyle: dot
- Thunderbird:
icon: mdi-server-network-#06b6d4
href: http://192.168.1.150:5800
description: Thunderbird
siteMonitor: http://192.168.1.150:5800
statusStyle: dot
- Excalidraw:
icon: excalidraw.png
href: http://192.168.1.211:3765
description: Excalidraw
siteMonitor: http://192.168.1.211:3765
statusStyle: dot
- CRM:
icon: mdi-server-network-#06b6d4
href: https://www.fixaanhuis.nl/crm
description: CRM
- Gitea:
icon: gitea.png
href: http://192.168.1.211:3000
description: Gitea
siteMonitor: http://192.168.1.211:3000
statusStyle: dot
- Neo4j Browser:
icon: neo4j.png
href: http://192.168.1.211:49154
description: Neo4j Browser
siteMonitor: http://192.168.1.211:49154
statusStyle: dot
- OnlyOffice:
icon: mdi-server-network-#06b6d4
href: http://192.168.5.128/example
description: OnlyOffice
siteMonitor: http://192.168.5.128
statusStyle: dot
- BentoPDF:
icon: mdi-server-network-#06b6d4
href: http://192.168.1.150:3183
description: BentoPDF
siteMonitor: http://192.168.1.150:3183
statusStyle: dot
- Jellyfin Test:
icon: mdi-server-network-#06b6d4
href: http://192.168.1.99:8096
description: Jellyfin Test
siteMonitor: http://192.168.1.99:8096
statusStyle: dot
- Tools & Utils:
- Traccar:
icon: traccar.png
href: http://192.168.1.150:8082
description: Traccar
siteMonitor: http://192.168.1.150:8082
statusStyle: dot
- Mytkstar:
icon: mdi-server-network-#f59e0b
href: https://www.mytkstar.net/Monitor.aspx
description: Mytkstar
- Firefox:
icon: mdi-server-network-#f59e0b
href: https://192.168.1.150:5813
description: Firefox
siteMonitor: https://192.168.1.150:5813
statusStyle: dot
- Background Remover:
icon: mdi-server-network-#f59e0b
href: http://192.168.1.150:8519
description: Background Remover
siteMonitor: http://192.168.1.150:8519
statusStyle: dot
- Neko:
icon: mdi-server-network-#f59e0b
href: http://192.168.1.150:8080
description: Neko
siteMonitor: http://192.168.1.150:8080
statusStyle: dot
- AI Assistants:
- Phind:
icon: mdi-server-network-#ec4899
href: https://www.phind.com/search?home=true
description: Phind
- Deepseek:
icon: si-deepseek-#ec4899
href: https://chat.deepseek.com
description: Deepseek
- Anthropic:
icon: si-anthropic-#ec4899
href: https://console.anthropic.com/dashboard
description: Anthropic
- Github:
icon: si-github-#ec4899
href: https://github.com/modammer020
description: Github
- Lbrty:
icon: mdi-lbrty-#ec4899
href: https://lbrty.ai
description: Lbrty
- Grok:
icon: si-x-#ec4899
href: https://grok.com
description: Grok
- LMarena:
icon: mdi-lmarena-#ec4899
href: https://lmarena.ai
description: LMarena
- Google AI studio:
icon: mdi-aistudio-#ec4899
href: https://aistudio.google.com/prompts/new_chat
description: Google AI studio
- GPT4FREE:
icon: mdi-server-network-#ec4899
href: http://192.168.1.150:6439
description: GPT4FREE
siteMonitor: http://192.168.1.150:6439
statusStyle: dot
- ChatGPT:
icon: si-openai-#ec4899
href: https://chatgpt.com
description: ChatGPT
- Youtube:
icon: si-youtube-#ec4899
href: https://youtube.com
description: Youtube
- Dev & Docs:
- DSM on Proxmox:
icon: proxmox.png
href: https://blog.nootch.net
description: DSM on Proxmox
- DSM Video station 7.2:
icon: mdi-github-#14b8a6
href: https://github.com/007revad/Video_Station_for_DSM_722?tab=readme-ov-file
description: DSM Video station 7.2
- Turnkey:
icon: mdi-server-network-#14b8a6
href: https://www.turnkeylinux.org
description: Turnkey
- LXC:
icon: mdi-community-sc-#14b8a6
href: https://community-scripts.github.io/ProxmoxVE
description: LXC
- DSM auxxxilium:
icon: mdi-arc-#14b8a6
href: https://arc.auxxxilium.tech
description: DSM auxxxilium
- Synology stuff (Marius Hosting):
icon: mdi-mariushostin-#14b8a6
href: https://mariushosting.com
description: Synology stuff (Marius Hosting)
- NZB Geek:
icon: mdi-nzbgeek-#14b8a6
href: https://nzbgeek.info/dashboard.php
description: NZB Geek
- Noted Apps:
icon: mdi-noted-#14b8a6
href: https://noted.lol
description: Noted Apps
- https://jack-vanlightly.com/:
icon: mdi-jack-vanligh-#14b8a6
href: https://jack-vanlightly.com
description: https://jack-vanlightly.com/
- Usenetdeal:
icon: mdi-usenetdeal-#14b8a6
href: https://usenetdeal.com/dashboard/usenet
description: Usenetdeal
- https://juhache.substack.com/:
icon: mdi-juhache-#14b8a6
href: https://juhache.substack.com
description: https://juhache.substack.com/
- Home Control:
icon: mdi-server-network-#14b8a6
href: http://192.168.1.211:8765/dashboard#live
description: Home Control
siteMonitor: http://192.168.1.211:8765
statusStyle: dot
- Web Design:
- HTML5 templates:
icon: mdi-jekyllthemes-#f97316
href: https://jekyllthemes.io/free
description: HTML5 templates
- HTML5 templates:
icon: mdi-jekyllrb-#f97316
href: https://jekyllrb.com/showcase
description: HTML5 templates
- HTML5 templates:
icon: mdi-freehtml5-#f97316
href: https://freehtml5.co
description: HTML5 templates
- HTML5:
icon: mdi-server-network-#f97316
href: https://www.tooplate.com/free-templates
description: HTML5
+77
View File
@@ -0,0 +1,77 @@
---
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:
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
+27
View File
@@ -0,0 +1,27 @@
---
- 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
+431
View File
@@ -0,0 +1,431 @@
/* EL-KADI OPS — Palantir blue + orange */
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap');
:root {
--p-void: #02060c;
--p-deep: #051018;
--p-surface: #0a1424;
--p-elevated: rgba(10, 22, 40, 0.92);
--p-border: rgba(56, 132, 220, 0.18);
--p-border-hot: rgba(251, 146, 60, 0.45);
--p-blue: #3b82f6;
--p-cyan: #22d3ee;
--p-orange: #fb923c;
--p-amber: #fbbf24;
--p-teal: #2dd4bf;
--p-text: #e8eef7;
--p-muted: #64748b;
--p-mono: 'JetBrains Mono', ui-monospace, monospace;
--p-sans: 'DM Sans', system-ui, sans-serif;
}
html { color-scheme: dark; }
body {
font-family: var(--p-sans) !important;
background: var(--p-void) !important;
color: var(--p-text) !important;
}
body::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(ellipse 100% 70% at 8% -15%, rgba(59, 130, 246, 0.22), transparent 52%),
radial-gradient(ellipse 80% 55% at 95% 5%, rgba(34, 211, 238, 0.12), transparent 48%),
radial-gradient(ellipse 60% 50% at 88% 75%, rgba(251, 146, 60, 0.14), transparent 45%),
radial-gradient(ellipse 50% 40% at 12% 85%, rgba(251, 146, 60, 0.08), transparent 40%),
linear-gradient(165deg, #061018 0%, #02060c 45%, #030810 100%);
pointer-events: none;
z-index: 0;
animation: bg-breathe 14s ease-in-out infinite alternate;
}
@keyframes bg-breathe {
0% { opacity: 1; }
100% { opacity: 0.9; }
}
body::after {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(59, 130, 246, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(59, 130, 246, 0.04) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, black 10%, transparent 72%);
pointer-events: none;
z-index: 0;
animation: grid-drift 40s linear infinite;
}
@keyframes grid-drift {
from { transform: translate(0, 0); }
to { transform: translate(-56px, -56px); }
}
.palantir-scanline {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1;
background: repeating-linear-gradient(0deg, transparent, transparent 3px, rgba(0,0,0,0.04) 3px, rgba(0,0,0,0.04) 6px);
opacity: 0.35;
}
.palantir-noise {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
#page_wrapper { position: relative; z-index: 2; }
#information-widget-greeting, .information-widget-greeting {
font-weight: 600 !important;
letter-spacing: 0.22em !important;
text-transform: uppercase !important;
font-size: 0.7rem !important;
color: var(--p-orange) !important;
font-family: var(--p-mono) !important;
}
.information-widget {
border: 1px solid var(--p-border) !important;
border-radius: 10px !important;
background: var(--p-elevated) !important;
backdrop-filter: blur(14px);
box-shadow: 0 4px 24px rgba(0,0,0,0.35), inset 0 1px 0 rgba(59,130,246,0.08);
}
button[role="tab"] {
font-family: var(--p-mono) !important;
font-size: 0.68rem !important;
letter-spacing: 0.14em !important;
text-transform: uppercase !important;
border: 1px solid var(--p-border) !important;
border-radius: 6px !important;
transition: all 0.25s ease;
}
button[role="tab"][aria-selected="true"] {
background: linear-gradient(135deg, rgba(59,130,246,0.2), rgba(251,146,60,0.12)) !important;
border-color: var(--p-border-hot) !important;
color: var(--p-cyan) !important;
box-shadow: 0 0 20px rgba(251,146,60,0.15);
}
.service-block, #services .service-group .service {
border: 1px solid var(--p-border) !important;
border-radius: 10px !important;
background: var(--p-elevated) !important;
backdrop-filter: blur(12px);
transition: border-color 0.2s, box-shadow 0.25s, transform 0.2s;
}
.service-block:hover, #services .service-group .service:hover {
border-color: rgba(251,146,60,0.5) !important;
box-shadow: 0 0 28px rgba(59,130,246,0.12), 0 0 12px rgba(251,146,60,0.1);
transform: translateY(-1px);
}
#services h2.service-group-name, .service-group-title {
font-family: var(--p-mono) !important;
font-size: 0.62rem !important;
letter-spacing: 0.2em !important;
text-transform: uppercase !important;
color: var(--p-cyan) !important;
}
#bookmarks .bookmark-group h2, .bookmark-group-name {
font-family: var(--p-mono) !important;
font-size: 0.62rem !important;
letter-spacing: 0.18em !important;
text-transform: uppercase !important;
color: var(--p-orange) !important;
}
#bookmarks a {
border: 1px solid var(--p-border) !important;
transition: border-color 0.2s, box-shadow 0.2s;
}
#bookmarks a:hover {
border-color: rgba(251,146,60,0.4) !important;
box-shadow: 0 0 16px rgba(59,130,246,0.1);
}
.rss-hub {
margin: 1rem 1.5rem 1.5rem;
padding: 1.25rem 1.5rem;
border: 1px solid var(--p-border);
border-radius: 14px;
background: linear-gradient(145deg, rgba(10,22,40,0.95), rgba(6,12,22,0.98));
box-shadow: 0 8px 40px rgba(0,0,0,0.45), inset 0 1px 0 rgba(59,130,246,0.12);
backdrop-filter: blur(16px);
position: relative;
overflow: hidden;
}
.rss-hub::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--p-blue), var(--p-orange), var(--p-cyan), transparent);
animation: rss-scan 4s ease-in-out infinite;
}
@keyframes rss-scan {
0%, 100% { opacity: 0.5; transform: scaleX(0.6); }
50% { opacity: 1; transform: scaleX(1); }
}
.rss-hub-title {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.5rem 1rem;
margin-bottom: 1.25rem;
font-family: var(--p-mono);
font-size: 0.72rem;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--p-cyan);
}
.rss-hub-accent {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
background: var(--p-orange);
box-shadow: 0 0 12px var(--p-orange);
animation: pulse-dot 2s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(0.85); }
}
.rss-hub-sub {
font-size: 0.58rem;
letter-spacing: 0.1em;
color: var(--p-muted);
text-transform: none;
}
.rss-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
@media (max-width: 1280px) { .rss-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 640px) { .rss-grid { grid-template-columns: 1fr; } }
.rss-column {
border: 1px solid rgba(59,130,246,0.12);
border-radius: 10px;
padding: 0.75rem;
background: rgba(5,12,24,0.6);
min-height: 160px;
}
.rss-column-head {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: var(--p-mono);
font-size: 0.58rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--p-orange);
margin-bottom: 0.65rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--p-border);
}
.rss-pulse {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--p-cyan);
animation: pulse-dot 1.8s ease-in-out infinite;
}
.rss-feed-block { list-style: none; margin-bottom: 0.85rem; }
.rss-feed-name {
display: block;
font-family: var(--p-mono);
font-size: 0.55rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--p-muted);
margin-bottom: 0.35rem;
}
.rss-items { list-style: none; padding: 0; margin: 0; }
.rss-items li {
display: flex;
justify-content: space-between;
gap: 0.5rem;
padding: 0.28rem 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
font-size: 0.72rem;
line-height: 1.35;
animation: rss-fade-in 0.4s ease forwards;
opacity: 0;
}
.rss-items li:nth-child(1) { animation-delay: 0.05s; }
.rss-items li:nth-child(2) { animation-delay: 0.1s; }
.rss-items li:nth-child(3) { animation-delay: 0.15s; }
.rss-items li:nth-child(4) { animation-delay: 0.2s; }
.rss-items li:nth-child(5) { animation-delay: 0.25s; }
@keyframes rss-fade-in { to { opacity: 1; } }
.rss-items a {
color: var(--p-text) !important;
text-decoration: none;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rss-items a:hover { color: var(--p-orange) !important; }
.rss-items time {
font-family: var(--p-mono);
font-size: 0.58rem;
color: var(--p-muted);
flex-shrink: 0;
}
.rss-loading { color: var(--p-muted); font-style: italic; }
.rss-error a { color: var(--p-orange) !important; }
.status-dot-status-green, .text-green-500 {
box-shadow: 0 0 8px rgba(74, 222, 128, 0.55);
}
footer, #footer {
opacity: 0.35;
font-family: var(--p-mono);
font-size: 0.62rem;
}
/* ─── Logo gradients + profile photo ─── */
:root {
--color-logo-start: 59, 130, 246;
--color-logo-stop: 251, 146, 60;
}
.information-widget-logo img,
.information-widget-logo a img,
#information-widgets .information-widget-logo img {
width: 52px !important;
height: 52px !important;
min-width: 52px !important;
min-height: 52px !important;
border-radius: 50% !important;
object-fit: cover !important;
border: 2px solid rgba(251, 146, 60, 0.55) !important;
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.35), 0 4px 16px rgba(0, 0, 0, 0.4) !important;
filter: none !important;
animation: none !important;
}
.information-widget-logo:hover img {
border-color: rgba(34, 211, 238, 0.7) !important;
}
.service-icon {
position: relative !important;
border-radius: 8px !important;
margin: 2px 4px 2px 2px !important;
background: rgba(12, 22, 38, 0.85) !important;
border: 1px solid rgba(59, 130, 246, 0.12) !important;
box-shadow: none !important;
animation: none !important;
overflow: hidden;
}
.service-icon::before,
.service-icon::after {
display: none !important;
content: none !important;
}
.service-icon > div { filter: none !important; transition: transform 0.2s ease; }
.service-icon img { filter: saturate(1.12) contrast(1.02) !important; transition: transform 0.2s ease; }
.service-card:hover .service-icon {
border-color: rgba(251, 146, 60, 0.35) !important;
box-shadow: 0 0 0 1px rgba(251, 146, 60, 0.15) !important;
}
.service-card:hover .service-icon > div,
.service-card:hover .service-icon img { transform: scale(1.06); }
.services-list .service:nth-child(6n+1) .service-icon { border-left: 2px solid #3b82f6 !important; }
.services-list .service:nth-child(6n+2) .service-icon { border-left: 2px solid #a855f7 !important; }
.services-list .service:nth-child(6n+3) .service-icon { border-left: 2px solid #22c55e !important; }
.services-list .service:nth-child(6n+4) .service-icon { border-left: 2px solid #06b6d4 !important; }
.services-list .service:nth-child(6n+5) .service-icon { border-left: 2px solid #f59e0b !important; }
.services-list .service:nth-child(6n+6) .service-icon { border-left: 2px solid #ec4899 !important; }
.bookmark-icon { font-weight: 600 !important; color: #fff !important; animation: none !important; box-shadow: none !important; }
.bookmark:nth-child(4n+1) .bookmark-icon { background: linear-gradient(135deg, #2563eb, #3b82f6) !important; }
.bookmark:nth-child(4n+2) .bookmark-icon { background: linear-gradient(135deg, #ea580c, #fb923c) !important; }
.bookmark:nth-child(4n+3) .bookmark-icon { background: linear-gradient(135deg, #7c3aed, #a855f7) !important; }
.bookmark:nth-child(4n+4) .bookmark-icon { background: linear-gradient(135deg, #059669, #22c55e) !important; }
.information-widget-resource .resource-icon { animation: none !important; filter: none !important; }
.information-widget-resource:nth-child(1) .resource-icon { color: #60a5fa !important; }
.information-widget-resource:nth-child(2) .resource-icon { color: #fb923c !important; }
.information-widget-resource:nth-child(3) .resource-icon { color: #4ade80 !important; }
.resource-usage > div {
background: linear-gradient(90deg, #3b82f6, #fb923c) !important;
animation: none !important;
background-size: 100% 100% !important;
}
.status-dot-status-green, .text-green-500 {
background-color: #4ade80 !important;
animation: status-pulse-green 3s ease-out infinite;
}
.status-dot-status-red, .text-red-500 {
background-color: #f87171 !important;
animation: status-pulse-red 3s ease-out infinite;
}
@keyframes status-pulse-green {
0% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.5); }
70% { box-shadow: 0 0 0 6px rgba(74, 222, 128, 0); }
}
@keyframes status-pulse-red {
0% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.5); }
70% { box-shadow: 0 0 0 6px rgba(248, 113, 113, 0); }
}
button[role="tab"][aria-selected="true"] { animation: none !important; }
.service-card { animation: none !important; }
.rss-hub { animation: none !important; }
@media (prefers-reduced-motion: reduce) {
body::before, body::after, .rss-hub::before, .rss-pulse, .rss-hub-accent,
.service-icon, .service-icon::before, .service-icon::after, .service-card,
.bookmark-icon, .information-widget-resource .resource-icon, button[role="tab"][aria-selected="true"],
.rss-hub, #information-widgets img { animation: none !important; }
}
+64
View File
@@ -0,0 +1,64 @@
#!/bin/sh
# Deploy Homepage LXC (CT 120) on pve 192.168.1.216
set -e
PW="${PROXMOX_PASSWORD:-WaQTUw2t}"
PVE=192.168.1.216
VMID=120
APP="${HOMEPAGE_DIR:-/volume1/docker/homelab-configs/apps/homepage}"
STORAGE="${PVE_STORAGE:-Storage}"
ssh_pve() {
docker run --rm -v "$APP:/src:ro" alpine sh -c "
apk add --no-cache openssh-client sshpass rsync >/dev/null 2>&1
sshpass -p '$PW' ssh -o StrictHostKeyChecking=no root@$PVE \"$1\"
"
}
echo "=== Proxmox: LXC $VMID homepage ==="
if ssh_pve "pct status $VMID" 2>/dev/null | grep -q running; then
echo "CT $VMID bestaat al en draait"
else
if ssh_pve "pct status $VMID" 2>/dev/null | grep -q stopped; then
echo "Start bestaande CT $VMID"
ssh_pve "pct start $VMID"
else
echo "Maak CT $VMID aan..."
ssh_pve "pct create $VMID local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst \
--arch amd64 --cores 2 --memory 2048 --swap 512 \
--hostname homepage --password '$PW' \
--rootfs ${STORAGE}:8 \
--net0 name=eth0,bridge=vmbr0,ip=dhcp,type=veth \
--features nesting=1,keyctl=1 --unprivileged 1 --onboot 1"
ssh_pve "pct start $VMID"
fi
fi
echo "Wacht op netwerk..."
sleep 8
echo "=== Docker installeren ==="
ssh_pve "pct exec $VMID -- bash -c 'export DEBIAN_FRONTEND=noninteractive; \
apt-get update -qq && apt-get install -y -qq docker.io docker-compose curl ca-certificates 2>/dev/null; \
systemctl enable --now docker'"
echo "=== Config genereren ==="
python3 "$APP/generate-config.py" 2>/dev/null || true
echo "=== Config uploaden ==="
# tar config and push
tar -C "$APP" -czf /tmp/homepage-deploy.tgz docker-compose.yml config public websites-add.txt
docker run --rm -v /tmp:/tmp alpine sh -c "
apk add --no-cache openssh-client sshpass >/dev/null 2>&1
sshpass -p '$PW' scp -o StrictHostKeyChecking=no /tmp/homepage-deploy.tgz root@$PVE:/tmp/
"
ssh_pve "pct exec $VMID -- mkdir -p /opt/homepage && pct push $VMID /tmp/homepage-deploy.tgz /tmp/homepage-deploy.tgz"
ssh_pve "pct exec $VMID -- bash -c 'cd /opt/homepage && tar xzf /tmp/homepage-deploy.tgz && rm /tmp/homepage-deploy.tgz'"
echo "=== Homepage starten ==="
ssh_pve "pct exec $VMID -- bash -c 'cd /opt/homepage && docker compose pull && docker compose up -d'"
echo "=== IP ophalen ==="
IP=$(ssh_pve "pct exec $VMID -- hostname -I" | awk '{print $1}')
echo ""
echo "Klaar: http://${IP:-192.168.1.216}:3000"
echo "Of via pve-host als poort 3000 op CT staat: check Proxmox → CT $VMID"
+17
View File
@@ -0,0 +1,17 @@
# Homepage Admin UI — draait op de NAS (grafisch sites toevoegen)
# Start: docker compose -f docker-compose.admin.yml up -d --build
services:
homepage-admin:
build: ./admin
container_name: homepage-admin
restart: unless-stopped
ports:
- "3010:3010"
environment:
PROXMOX_PASSWORD: ${PROXMOX_PASSWORD:-WaQTUw2t}
HOMEPAGE_DIR: /apps/homepage
volumes:
- /volume1/docker/homelab-configs/apps:/apps:rw
- /var/run/docker.sock:/var/run/docker.sock
working_dir: /apps/homepage/admin
command: python server.py
+18
View File
@@ -0,0 +1,18 @@
services:
homepage:
image: ghcr.io/gethomepage/homepage:latest
container_name: homepage
restart: unless-stopped
ports:
- "3000:3000"
environment:
# Homelab LAN: * = geen host-check (DHCP/NPM hostnames wisselen)
HOMEPAGE_ALLOWED_HOSTS: "*"
PUID: 0
PGID: 0
volumes:
- ./config:/app/config
- ./config/custom.css:/app/config/custom.css:ro
- ./config/custom.js:/app/config/custom.js:ro
- ./public/images:/app/public/images:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
+372
View File
@@ -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()
+18
View File
@@ -0,0 +1,18 @@
# Profielfoto (logo)
Plaats je foto hier als **`logo.jpg`** (vierkant werkt het best, min. 128×128 px).
```bash
cp /pad/naar/jouw-foto.jpg logo.jpg
cd /volume1/docker/homelab-configs/apps/homepage
./deploy-to-pve.sh
```
Of gebruik het script:
```bash
./set-logo.sh /pad/naar/foto.jpg
./deploy-to-pve.sh
```
Daarna op Homepage: refresh-icoon rechtsonder, of Ctrl+Shift+R.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Kopieer je profielfoto naar Homepage logo
set -e
SRC="${1:?Gebruik: ./set-logo.sh /pad/naar/foto.jpg}"
DEST="$(dirname "$0")/public/images/logo.jpg"
mkdir -p "$(dirname "$DEST")"
cp "$SRC" "$DEST"
echo "Logo gezet: $DEST"
echo "Deploy: ./deploy-to-pve.sh"
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Start Homepage Admin UI op de NAS
set -e
cd "$(dirname "$0")"
if docker compose version >/dev/null 2>&1; then
docker compose -f docker-compose.admin.yml up -d --build
else
docker-compose -f docker-compose.admin.yml up -d --build
fi
echo ""
echo "Admin UI: http://192.168.1.211:3010"
echo "Dashboard: http://192.168.1.192:3000"
+7
View File
@@ -0,0 +1,7 @@
# Extra websites — één per regel: groep|naam|url
# Voorbeeld:
# Productivity|Jellyfin|http://192.168.1.10:8096
# AI Assistants|Claude|https://claude.ai
#
# Of gebruik: ./add-website.sh
Productivity|Jellyfin Test|http://192.168.1.99:8096
+2
View File
@@ -0,0 +1,2 @@
# (optioneel) Zie websites-add.txt — dat is het makkelijkste bestand om te bewerken.
# Gebruik: ./add-website.sh of bewerk websites-add.txt
+11
View File
@@ -0,0 +1,11 @@
arch: amd64
cores: 2
features: nesting=1,keyctl=1
hostname: homepage
memory: 2048
net0: name=eth0,bridge=vmbr0,hwaddr=BC:24:11:D3:74:DD,ip=dhcp,type=veth
onboot: 1
ostype: debian
rootfs: Storage:vm-120-disk-0,size=8G
swap: 512
unprivileged: 1
+1
View File
@@ -33,6 +33,7 @@ Configs: `hosts/<naam>/lxc/*.conf` · App-configs: `apps/<hostname>/`
| 117 | Proxy | 192.168.1.165 | [proxy](../proxy/) | **running** | | 117 | Proxy | 192.168.1.165 | [proxy](../proxy/) | **running** |
| 118 | paymenter | 192.168.1.45 | [paymenter](../paymenter/) | **running** | | 118 | paymenter | 192.168.1.45 | [paymenter](../paymenter/) | **running** |
| 119 | nodecast-tv | 192.168.1.99 | [nodecast-tv](../nodecast-tv/) | **running** | | 119 | nodecast-tv | 192.168.1.99 | [nodecast-tv](../nodecast-tv/) | **running** |
| 120 | homepage | 192.168.1.192 | [homepage](../homepage/) | **running** |
## Host: dell-proxmox (192.168.1.56) ## Host: dell-proxmox (192.168.1.56)