Files
dashboard/script.js
T

201 lines
8.3 KiB
JavaScript

// ---------------------------------------------------------------------------
// 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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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);