universal-dashboard/script.js

571 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const STORAGE_KEY = "ecosystem-panel-preferences";
// Меняй навигацию здесь: разделы -> подразделы -> элементы.
// url можно заменить на реальные адреса, tags помогают поиску.
const ecosystemSections = [
{
id: "user",
number: "1",
title: "Интерфейс пользователя",
description: "Рабочие инструменты пользователя для фильтрации и обработки сообщений.",
groups: [
{
id: "message-filtering",
number: "1.1",
title: "Управление фильтрацией сообщений",
description: "Правила, задания, события и статистика фильтрации.",
items: [
createItem("1.1.1", "Редактирование правил", "rules", ["правила", "фильтрация", "редактор"]),
createItem("1.1.2", "Управление заданиями", "redmine", ["redmine", "задачи", "задания"]),
createItem("1.1.3", "Применение правил", "rules", ["правила", "применение", "фильтрация"]),
createItem("1.1.4", "Просмотр статистики", "grafana", ["grafana", "статистика", "дашборд"]),
createItem("1.1.5", "Управление событиями фильтрации", "grafana", ["grafana", "события", "алерты"]),
createItem("1.1.6", "Просмотр журнала событий фильтрации", "grafana", ["grafana", "журнал", "события"]),
],
},
{
id: "message-processing",
number: "1.2",
title: "Обработка сообщений",
description: "Просмотр, оформление и доступ к хранилищам сообщений.",
items: [
createItem("1.2.1", "Просмотр и оформление", "messages", ["сообщения", "просмотр", "оформление"]),
createItem("1.2.2", "Прямой доступ", "ceph", ["ceph", "доступ", "объекты"]),
createItem("1.2.3", "S3 Хранилище", "ceph", ["ceph", "s3", "хранилище"]),
],
},
],
},
{
id: "admin",
number: "2",
title: "Интерфейс администратора",
description: "Администрирование сервисов, кластеров, мониторинга и аппаратной части.",
groups: [
{
id: "service-monitoring",
number: "2.1",
title: "Управление и мониторинг сервисов",
description: "Кластер, модули, события мониторинга и журналы ошибок.",
items: [
createItem("2.1.1", "Управление кластером", "kubernetes", ["kubernetes", "кластер", "k8s"]),
createItem("2.1.2", "Мониторинг состояния модулей", "grafana", ["grafana", "модули", "мониторинг"]),
createItem("2.1.3", "Управление событиями мониторинга", "grafana", ["grafana", "события", "алерты"]),
createItem("2.1.4", "Просмотр журнала событий мониторинга", "grafana", ["grafana", "журнал", "мониторинг"]),
createItem("2.1.5", "Просмотр журналов ошибок", "opensearch", ["opensearch", "ошибки", "логи"]),
],
},
{
id: "service-configuration",
number: "2.2",
title: "Настройка сервисов",
description: "Пользователи, очереди, xPath, модули и конфигурации.",
items: [
createItem("2.2.1", "Управление пользователями и очередями", "rabbitmq", ["пользователи", "очереди", "rabbitmq"]),
createItem("2.2.2", "Отладка xPath-запросов", "xpath", ["xpath", "отладка", "запросы"]),
createItem("2.2.3", "Конфигурирование модулей", "config", ["конфигурация", "модули", "настройки"]),
createItem("2.2.4", "Просмотр журнала конфигурирования", "gitea", ["gitea", "журнал", "конфигурация"]),
createItem("2.2.5", "Прямой доступ к конфигурации", "gitea", ["gitea", "конфигурация", "git"]),
],
},
{
id: "hardware",
number: "2.3",
title: "Управление и мониторинг аппаратной части",
description: "Сенсоры, ресурсы и прямые панели серверов.",
items: [
createItem("2.3.1", "Мониторинг показаний сенсоров", "grafana", ["grafana", "сенсоры", "железо"]),
createItem("2.3.2", "Мониторинг потребления ресурсов", "grafana", ["grafana", "ресурсы", "метрики"]),
createItem("2.3.3", "СЕРВЕР01", "ipmi", ["ipmi", "сервер01", "железо"]),
createItem("2.3.4", "СЕРВЕР02", "ipmi", ["ipmi", "сервер02", "железо"]),
createItem("2.3.5", "СЕРВЕР03", "ipmi", ["ipmi", "сервер03", "железо"]),
createItem("2.3.6", "СЕРВЕР04", "ipmi", ["ipmi", "сервер04", "железо"]),
createItem("2.3.7", "СЕРВЕР05", "ipmi", ["ipmi", "сервер05", "железо"]),
],
},
{
id: "service-panels",
number: "2.4",
title: "Настройка отдельных сервисов",
description: "Панели управления инфраструктурными сервисами.",
items: [
createItem("2.4.1", "Панель управления LDAP Account Manager", "ldap", ["ldap", "accounts", "пользователи"]),
createItem("2.4.2", "Панель управления кластера RabbitMQ", "rabbitmq", ["rabbitmq", "кластер", "очереди"]),
createItem("2.4.3", "Панель управления кластера Ceph", "ceph", ["ceph", "кластер", "storage"]),
createItem("2.4.4", "Панель управления Prometheus", "prometheus", ["prometheus", "метрики", "мониторинг"]),
createItem("2.4.5", "Панель управления XMPP-сервера", "xmpp", ["xmpp", "чат", "сервер"]),
createItem("2.4.6", "Панель управления PostgreSQL", "postgresql", ["postgresql", "база", "db"]),
createItem("2.4.7", "Подключение к БД", "cloudbeaver", ["cloudbeaver", "database", "sql"]),
],
},
{
id: "packages",
number: "2.5",
title: "Образы и пакеты",
description: "Реестры образов и deb-пакетов.",
items: [
createItem("2.5.1", "Реестр docker-образов", "gitea", ["gitea", "docker", "registry"]),
createItem("2.5.2", "Репозиторий deb-пакетов", "gitea", ["gitea", "deb", "packages"]),
],
},
],
},
];
const serviceMeta = {
ceph: { label: "Ceph", icon: "assets/ceph.svg" },
cloudbeaver: { label: "CloudBeaver", icon: "assets/database.svg" },
config: { label: "Config", icon: "assets/config.svg" },
gitea: { label: "Gitea", icon: "assets/gitea.svg" },
grafana: { label: "Grafana", icon: "assets/grafana.svg" },
ipmi: { label: "IPMI", icon: "assets/server.svg" },
kubernetes: { label: "Kubernetes", icon: "assets/kubernetes.svg" },
ldap: { label: "LDAP", icon: "assets/ldap.svg" },
messages: { label: "Messages", icon: "assets/messages.svg" },
opensearch: { label: "OpenSearch", icon: "assets/opensearch.svg" },
postgresql: { label: "PostgreSQL", icon: "assets/database.svg" },
prometheus: { label: "Prometheus", icon: "assets/prometheus.svg" },
rabbitmq: { label: "RabbitMQ", icon: "assets/rabbitmq.svg" },
redmine: { label: "Redmine", icon: "assets/redmine.svg" },
rules: { label: "Rules", icon: "assets/rules.svg" },
xpath: { label: "xPath", icon: "assets/xpath.svg" },
xmpp: { label: "XMPP", icon: "assets/xmpp.svg" },
};
const defaultPreferences = {
theme: window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark",
view: "cards",
accent: "violet",
showNumbers: false,
};
const availableViews = ["cards", "list", "compact-cards", "compact-list"];
const preferences = { ...defaultPreferences, ...readPreferences() };
const catalog = document.querySelector("[data-catalog-root]");
const navTree = document.querySelector("[data-nav-tree]");
const searchInput = document.querySelector("[data-search-input]");
const settingsToggle = document.querySelector("[data-settings-toggle]");
const settingsPanel = document.querySelector("[data-settings-panel]");
const settingsOverlay = document.querySelector("[data-settings-overlay]");
const settingsClose = document.querySelector("[data-settings-close]");
const themeButtons = document.querySelectorAll("[data-theme]");
const viewButtons = document.querySelectorAll("[data-view]");
const accentButtons = document.querySelectorAll("[data-accent]");
const showNumbersInput = document.querySelector("[data-show-numbers]");
const emptyTemplate = document.querySelector("#empty-template");
const totals = calculateTotals(ecosystemSections);
document.querySelector("[data-total-sections]").textContent = totals.sections;
document.querySelector("[data-total-groups]").textContent = totals.groups;
document.querySelector("[data-total-items]").textContent = totals.items;
renderNavigationTree();
applyPreferences(preferences);
renderCatalog();
searchInput.addEventListener("input", renderCatalog);
navTree.addEventListener("click", (event) => {
const button = event.target.closest("[data-focus-target]");
if (!button) {
return;
}
event.preventDefault();
event.stopPropagation();
focusCatalogTarget(button.dataset.focusTarget);
});
settingsToggle.addEventListener("click", () => {
const isOpen = settingsPanel.classList.toggle("is-open");
settingsOverlay.classList.toggle("is-open", isOpen);
settingsToggle.setAttribute("aria-expanded", String(isOpen));
});
settingsClose.addEventListener("click", closeSettingsPanel);
settingsOverlay.addEventListener("click", closeSettingsPanel);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeSettingsPanel();
}
});
document.addEventListener("click", (event) => {
if (!settingsPanel.classList.contains("is-open")) {
return;
}
if (settingsPanel.contains(event.target) || settingsToggle.contains(event.target)) {
return;
}
closeSettingsPanel();
});
themeButtons.forEach((button) => {
button.addEventListener("click", () => {
preferences.theme = button.dataset.theme;
applyPreferences(preferences);
savePreferences(preferences);
});
});
viewButtons.forEach((button) => {
button.addEventListener("click", () => {
preferences.view = button.dataset.view;
applyPreferences(preferences);
savePreferences(preferences);
});
});
accentButtons.forEach((button) => {
button.addEventListener("click", () => {
preferences.accent = button.dataset.accent;
applyPreferences(preferences);
savePreferences(preferences);
});
});
showNumbersInput.addEventListener("change", () => {
preferences.showNumbers = showNumbersInput.checked;
applyPreferences(preferences);
savePreferences(preferences);
});
function createItem(number, title, type, tags = [], url = "#") {
return { number, title, type, tags, url };
}
function renderCatalog() {
const query = normalize(searchInput.value);
const fragment = document.createDocumentFragment();
let visibleItems = 0;
ecosystemSections.forEach((section) => {
const sectionElement = createSectionElement(section);
let visibleGroups = 0;
let sectionVisibleItems = 0;
section.groups.forEach((group) => {
const filteredItems = group.items.filter((item) => matchesQuery(item, group, section, query));
if (!filteredItems.length) {
return;
}
visibleGroups += 1;
sectionVisibleItems += filteredItems.length;
sectionElement.querySelector(".section-groups").append(createGroupElement(group, filteredItems));
});
if (visibleGroups) {
sectionElement.querySelector("[data-section-count]").textContent = sectionVisibleItems;
fragment.append(sectionElement);
visibleItems += sectionVisibleItems;
}
});
catalog.replaceChildren();
if (visibleItems) {
catalog.append(fragment);
attachCardInteractions();
} else {
catalog.append(emptyTemplate.content.cloneNode(true));
}
document.querySelector("[data-visible-items]").textContent = visibleItems;
}
function createSectionElement(section) {
const sectionElement = document.createElement("details");
sectionElement.className = "catalog-section";
sectionElement.id = getSectionId(section);
sectionElement.tabIndex = -1;
sectionElement.open = true;
sectionElement.innerHTML = `
<summary class="section-head">
<div>
<p class="section-number">${section.number} раздел</p>
<h2>${section.title}</h2>
<p>${section.description}</p>
</div>
<span class="section-meta">
<span class="section-count"><span data-section-count>0</span> элементов</span>
<span class="collapse-indicator" aria-hidden="true">⌄</span>
</span>
</summary>
<div class="section-groups"></div>
`;
return sectionElement;
}
function createGroupElement(group, items) {
const groupElement = document.createElement("details");
groupElement.className = "catalog-group";
groupElement.id = getGroupId(group);
groupElement.tabIndex = -1;
groupElement.open = true;
const cards = items.map(createCardElement);
groupElement.innerHTML = `
<summary class="group-head">
<div>
<p class="group-number">${group.number}</p>
<h3>${group.title}</h3>
<p>${group.description}</p>
</div>
<span class="group-meta">
<span class="group-count">${items.length}</span>
<span class="collapse-indicator" aria-hidden="true">⌄</span>
</span>
</summary>
`;
const grid = document.createElement("div");
grid.className = "items-grid";
grid.append(...cards);
groupElement.append(grid);
return groupElement;
}
function createCardElement(item) {
const meta = serviceMeta[item.type] || serviceMeta.config;
const link = document.createElement("a");
link.className = "nav-card";
link.href = item.url;
link.id = getItemId(item);
link.dataset.type = item.type;
if (item.url === "#") {
link.setAttribute("aria-label", `${item.title}: URL еще не настроен`);
link.addEventListener("click", (event) => event.preventDefault());
} else {
link.target = "_blank";
link.rel = "noopener noreferrer";
}
link.innerHTML = `
<span class="shine"></span>
<img src="${meta.icon}" alt="" loading="lazy" />
<div class="card-content">
<p class="item-number">${item.number}</p>
<h4>${item.title}</h4>
<p class="item-type">${meta.label}</p>
<div class="tags">${item.tags.map((tag) => `<span>${tag}</span>`).join("")}</div>
</div>
<span class="arrow" aria-hidden="true">→</span>
`;
return link;
}
function matchesQuery(item, group, section, query) {
if (!query) {
return true;
}
const meta = serviceMeta[item.type] || serviceMeta.config;
const haystack = normalize(
[
item.number,
item.title,
item.type,
meta.label,
item.tags.join(" "),
group.title,
section.title,
].join(" "),
);
return haystack.includes(query);
}
function renderNavigationTree() {
const fragment = document.createDocumentFragment();
ecosystemSections.forEach((section) => {
const sectionNode = document.createElement("details");
sectionNode.className = "tree-section";
sectionNode.open = true;
const sectionSummary = document.createElement("summary");
sectionSummary.innerHTML = `
<span class="tree-caret" aria-hidden="true"></span>
<button class="tree-node tree-folder" type="button" data-focus-target="${getSectionId(section)}">
<span>${section.title}</span>
</button>
`;
sectionNode.append(sectionSummary);
section.groups.forEach((group) => {
const groupNode = document.createElement("details");
groupNode.className = "tree-group";
groupNode.open = true;
const groupSummary = document.createElement("summary");
groupSummary.innerHTML = `
<span class="tree-caret" aria-hidden="true"></span>
<button class="tree-node tree-folder" type="button" data-focus-target="${getGroupId(group)}">
<span>${group.title}</span>
</button>
`;
groupNode.append(groupSummary);
const itemList = document.createElement("div");
itemList.className = "tree-items";
group.items.forEach((item) => {
const itemButton = document.createElement("button");
itemButton.type = "button";
itemButton.className = "tree-node tree-file";
itemButton.dataset.focusTarget = getItemId(item);
itemButton.innerHTML = `<span>${item.title}</span><small>${serviceMeta[item.type]?.label || item.type}</small>`;
itemList.append(itemButton);
});
groupNode.append(itemList);
sectionNode.append(groupNode);
});
fragment.append(sectionNode);
});
navTree.replaceChildren(fragment);
}
function focusCatalogTarget(targetId) {
let target = document.getElementById(targetId);
if (!target) {
searchInput.value = "";
renderCatalog();
target = document.getElementById(targetId);
}
if (!target) {
return;
}
target.closest(".catalog-section")?.setAttribute("open", "");
target.closest(".catalog-group")?.setAttribute("open", "");
target.scrollIntoView({ behavior: "smooth", block: "center" });
target.focus({ preventScroll: true });
target.classList.add("is-focused");
window.setTimeout(() => target.classList.remove("is-focused"), 1200);
}
function attachCardInteractions() {
document.querySelectorAll(".nav-card").forEach((card) => {
let leaveTimer;
card.addEventListener("pointerenter", () => {
window.clearTimeout(leaveTimer);
card.classList.add("is-hovered");
});
card.addEventListener("pointermove", (event) => {
window.clearTimeout(leaveTimer);
card.classList.add("is-hovered");
const rect = card.getBoundingClientRect();
const xPercent = clamp(((event.clientX - rect.left) / rect.width) * 100, 8, 92);
const yPercent = clamp(((event.clientY - rect.top) / rect.height) * 100, 8, 92);
const rotateY = ((xPercent - 50) / 50) * 2.4;
const rotateX = ((50 - yPercent) / 50) * 2.4;
card.style.setProperty("--card-x", `${xPercent}%`);
card.style.setProperty("--card-y", `${yPercent}%`);
card.style.setProperty("--tilt-x", `${rotateX}deg`);
card.style.setProperty("--tilt-y", `${rotateY}deg`);
});
card.addEventListener("pointerleave", () => {
leaveTimer = window.setTimeout(() => {
card.classList.remove("is-hovered");
card.style.setProperty("--card-x", "50%");
card.style.setProperty("--card-y", "50%");
card.style.setProperty("--tilt-x", "0deg");
card.style.setProperty("--tilt-y", "0deg");
}, 80);
});
});
}
function applyPreferences(nextPreferences) {
if (!availableViews.includes(nextPreferences.view)) {
nextPreferences.view = defaultPreferences.view;
}
document.documentElement.dataset.theme = nextPreferences.theme;
document.documentElement.dataset.accent = nextPreferences.accent;
catalog.classList.remove("cards-view", "list-view", "compact-cards-view", "compact-list-view");
catalog.classList.add(`${nextPreferences.view}-view`);
document.documentElement.dataset.showNumbers = String(Boolean(nextPreferences.showNumbers));
showNumbersInput.checked = Boolean(nextPreferences.showNumbers);
setActiveButton(themeButtons, "theme", nextPreferences.theme);
setActiveButton(viewButtons, "view", nextPreferences.view);
setActiveButton(accentButtons, "accent", nextPreferences.accent);
}
function setActiveButton(buttons, key, value) {
buttons.forEach((button) => {
const isActive = button.dataset[key] === value;
button.classList.toggle("is-active", isActive);
button.setAttribute("aria-pressed", String(isActive));
});
}
function calculateTotals(sections) {
return sections.reduce(
(total, section) => {
total.sections += 1;
total.groups += section.groups.length;
total.items += section.groups.reduce((sum, group) => sum + group.items.length, 0);
return total;
},
{ sections: 0, groups: 0, items: 0 },
);
}
function normalize(value) {
return value.toLowerCase().replaceAll("ё", "е").trim();
}
function getSectionId(section) {
return `section-${section.id}`;
}
function getGroupId(group) {
return `group-${group.id}`;
}
function getItemId(item) {
return `item-${item.number.replaceAll(".", "-")}`;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function readPreferences() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
} catch {
return {};
}
}
function savePreferences(nextPreferences) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(nextPreferences));
}
function closeSettingsPanel() {
settingsPanel.classList.remove("is-open");
settingsOverlay.classList.remove("is-open");
settingsToggle.setAttribute("aria-expanded", "false");
}