Add static homelab dashboard with Docker and nginx setup
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Homelab Dashboard
|
||||
// Edit this array to add, remove, or reorganise services.
|
||||
// section : "internal" | "external"
|
||||
// name : display name
|
||||
// url : full URL (with http:// or https://)
|
||||
// cat : a key from the CATEGORIES map below
|
||||
// icon : an emoji (or any short text)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CATEGORIES = {
|
||||
ai: { label: "AI", color: "#a855f7" },
|
||||
dev: { label: "Development", color: "#3b82f6" },
|
||||
media: { label: "Media", color: "#ec4899" },
|
||||
photos: { label: "Photos", color: "#14b8a6" },
|
||||
smartHome: { label: "Smart Home", color: "#10b981" },
|
||||
audio: { label: "Audio", color: "#f97316" },
|
||||
storage: { label: "Storage", color: "#6366f1" },
|
||||
security: { label: "Security", color: "#f43f5e" },
|
||||
infra: { label: "Infrastructure", color: "#f59e0b" },
|
||||
network: { label: "Networking", color: "#0ea5e9" },
|
||||
external: { label: "External", color: "#22d3ee" },
|
||||
};
|
||||
|
||||
const SERVICES = [
|
||||
// ----- Internal -----
|
||||
{ section: "internal", name: "Open WebUI", url: "http://100.103.83.12:3000", cat: "ai", icon: "🤖" },
|
||||
{ section: "internal", name: "ComfyUI", url: "http://100.103.83.12:8288", cat: "ai", icon: "🎨" },
|
||||
{ section: "internal", name: "Gitea", url: "http://100.103.83.12:3003", cat: "dev", icon: "🐙" },
|
||||
{ section: "internal", name: "Jellyfin", url: "http://100.103.83.12:8096", cat: "media", icon: "🎬" },
|
||||
{ section: "internal", name: "Immich", url: "http://100.103.83.12:2283", cat: "photos", icon: "📸" },
|
||||
{ section: "internal", name: "Home Assistant", url: "http://192.168.178.45:8123", cat: "smartHome", icon: "🏠" },
|
||||
{ section: "internal", name: "Audio Server / Dubplate", url: "http://192.168.178.100:8180", cat: "audio", icon: "🎧" },
|
||||
{ section: "internal", name: "Nextcloud", url: "https://cloud.gg36", cat: "storage", icon: "☁️" },
|
||||
{ section: "internal", name: "Vaultwarden", url: "https://vault.gg36.at", cat: "security", icon: "🔐" },
|
||||
{ section: "internal", name: "Nginx Proxy Manager", url: "http://192.168.178.194:81", cat: "infra", icon: "⚙️" },
|
||||
{ section: "internal", name: "Netbird", url: "https://netbird.gg36.at", cat: "network", icon: "🛰️" },
|
||||
|
||||
// ----- External -----
|
||||
{ section: "external", name: "gitlab-ixsol", url: "https://gitlab.ixsol.wien", cat: "external", icon: "🐙" },
|
||||
{ section: "external", name: "gem360", url: "https://gem360.ixslo.wien", cat: "external", icon: "🎮" },
|
||||
];
|
||||
|
||||
const SECTION_META = {
|
||||
internal: { title: "Internal Services", order: 1 },
|
||||
external: { title: "External", order: 2 },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const app = document.getElementById("app");
|
||||
const searchInput = document.getElementById("search");
|
||||
const statsEl = document.getElementById("stats");
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function cardTemplate(s, idx) {
|
||||
const meta = CATEGORIES[s.cat] || { label: s.cat || "Service", color: "var(--accent)" };
|
||||
const urlHost = s.url.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
return `
|
||||
<article class="card" data-search="${escapeHtml((s.name + " " + meta.label + " " + s.url).toLowerCase())}"
|
||||
style="--cat:${meta.color}; animation-delay:${Math.min(idx * 35, 400)}ms">
|
||||
<div class="card-head">
|
||||
<div class="card-icon" aria-hidden="true">${s.icon || "🔗"}</div>
|
||||
<span class="status"><span class="dot"></span>Online</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<span class="card-cat">${escapeHtml(meta.label)}</span>
|
||||
<h3 class="card-name">${escapeHtml(s.name)}</h3>
|
||||
<span class="card-url" title="${escapeHtml(s.url)}">${escapeHtml(urlHost)}</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<a class="btn btn-primary" href="${escapeHtml(s.url)}" target="_blank" rel="noopener noreferrer">
|
||||
Open
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
|
||||
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M7 17 17 7M9 7h8v8"/>
|
||||
</svg>
|
||||
</a>
|
||||
<button class="btn btn-ghost" type="button" data-copy="${escapeHtml(s.url)}" title="Copy URL"
|
||||
aria-label="Copy URL for ${escapeHtml(s.name)}">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<rect x="9" y="9" width="11" height="11" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<path fill="none" stroke="currentColor" stroke-width="2" d="M5 15V5a2 2 0 0 1 2-2h10"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const grouped = {};
|
||||
for (const s of SERVICES) (grouped[s.section] ||= []).push(s);
|
||||
|
||||
const sections = Object.keys(grouped).sort(
|
||||
(a, b) => (SECTION_META[a]?.order ?? 99) - (SECTION_META[b]?.order ?? 99),
|
||||
);
|
||||
|
||||
app.innerHTML = sections
|
||||
.map((key) => {
|
||||
const meta = SECTION_META[key] || { title: key };
|
||||
const cards = grouped[key].map((s, i) => cardTemplate(s, i)).join("");
|
||||
return `
|
||||
<section class="section">
|
||||
<h2 class="section-title">${escapeHtml(meta.title)} <span class="count">${grouped[key].length}</span></h2>
|
||||
<div class="grid">${cards}</div>
|
||||
</section>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
statsEl.textContent = `${SERVICES.length} services · ${grouped.internal?.length || 0} internal · ${
|
||||
grouped.external?.length || 0
|
||||
} external`;
|
||||
|
||||
bindCopyButtons();
|
||||
}
|
||||
|
||||
function bindCopyButtons() {
|
||||
app.querySelectorAll("[data-copy]").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const url = btn.getAttribute("data-copy");
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
} catch {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = url;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
ta.remove();
|
||||
}
|
||||
const original = btn.innerHTML;
|
||||
btn.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16"><path fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>`;
|
||||
setTimeout(() => (btn.innerHTML = original), 1300);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search / filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function filter() {
|
||||
const q = searchInput.value.trim().toLowerCase();
|
||||
const cards = app.querySelectorAll(".card");
|
||||
let visible = 0;
|
||||
cards.forEach((card) => {
|
||||
const match = !q || card.getAttribute("data-search").includes(q);
|
||||
card.classList.toggle("hidden", !match);
|
||||
if (match) visible++;
|
||||
});
|
||||
|
||||
// hide sections with no visible cards
|
||||
app.querySelectorAll(".section").forEach((sec) => {
|
||||
const hasVisible = sec.querySelector(".card:not(.hidden)");
|
||||
sec.style.display = hasVisible ? "" : "none";
|
||||
});
|
||||
|
||||
let noRes = document.querySelector(".no-results");
|
||||
if (visible === 0) {
|
||||
if (!noRes) {
|
||||
noRes = document.createElement("div");
|
||||
noRes.className = "no-results";
|
||||
noRes.textContent = "No services match your search.";
|
||||
app.appendChild(noRes);
|
||||
}
|
||||
} else if (noRes) {
|
||||
noRes.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function tickClock() {
|
||||
const now = new Date();
|
||||
const time = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
const date = now.toLocaleDateString([], { weekday: "short", day: "numeric", month: "short", year: "numeric" });
|
||||
document.getElementById("clock-time").textContent = time;
|
||||
document.getElementById("clock-date").textContent = date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
render();
|
||||
tickClock();
|
||||
setInterval(tickClock, 1000);
|
||||
searchInput.addEventListener("input", filter);
|
||||
Reference in New Issue
Block a user