201 lines
8.3 KiB
JavaScript
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("<", "<")
|
|
.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);
|