200 lines
7.4 KiB
JavaScript
200 lines
7.4 KiB
JavaScript
// Интерактивная логика инженерного резюме Егора Зуева.
|
||
|
||
const incidentRunbooks = {
|
||
postgres: {
|
||
title: 'PostgreSQL: CPU 100% у одного тенанта',
|
||
steps: [
|
||
{
|
||
label: 'Локализация',
|
||
text: 'Проверяю SigNoz по `tenant.id`, затем на хосте БД смотрю `pg_stat_activity`, активные транзакции, блокировки и системную нагрузку.'
|
||
},
|
||
{
|
||
label: 'Восстановление',
|
||
text: 'Ограничиваю тяжелый запрос через `pg_cancel_backend`, проверяю пул HikariCP и возвращаю сервис в нормальный режим без затрагивания других тенантов.'
|
||
},
|
||
{
|
||
label: 'Профилактика',
|
||
text: 'Добавляю индекс или правлю запрос, включаю slow query logs и настраиваю алерт по CPU, активным сессиям и длительным транзакциям.'
|
||
}
|
||
]
|
||
},
|
||
tls: {
|
||
title: 'Caddy: TLS или 502 на новом домене',
|
||
steps: [
|
||
{
|
||
label: 'Локализация',
|
||
text: 'Проверяю DNS A/CNAME, логи Caddy через `journalctl`, доступность upstream-нод K3s и состояние Traefik Ingress.'
|
||
},
|
||
{
|
||
label: 'Восстановление',
|
||
text: 'Перезагружаю конфигурацию Caddy, проверяю rate limit ACME, при необходимости переключаю issuer и временно вывожу tenant из автопровижининга.'
|
||
},
|
||
{
|
||
label: 'Профилактика',
|
||
text: 'Добавляю предварительную проверку DNS перед созданием тенанта и мониторинг срока действия сертификатов через blackbox-проверки.'
|
||
}
|
||
]
|
||
},
|
||
runner: {
|
||
title: 'Gitea Actions: self-hosted runner завис',
|
||
steps: [
|
||
{
|
||
label: 'Локализация',
|
||
text: 'Смотрю `systemctl status`, `docker ps`, `docker logs`, свободное место через `df -h` и очередь job в Gitea Actions.'
|
||
},
|
||
{
|
||
label: 'Восстановление',
|
||
text: 'Очищаю зависшие контейнеры и кэш Docker, перезапускаю runner, затем вручную контролирую `kubectl rollout status` для backend/frontend.'
|
||
},
|
||
{
|
||
label: 'Профилактика',
|
||
text: 'Выношу runner на отдельный ZFS dataset с квотой, добавляю регулярную очистку слоев и алерты по свободному месту.'
|
||
}
|
||
]
|
||
}
|
||
};
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
initPrintButton();
|
||
initCopyButtons();
|
||
initReveal();
|
||
initNavigationHighlight();
|
||
initIncidentTabs();
|
||
});
|
||
|
||
function initPrintButton() {
|
||
const printButton = document.getElementById('btnPrint');
|
||
if (!printButton) return;
|
||
|
||
printButton.addEventListener('click', () => {
|
||
window.print();
|
||
});
|
||
}
|
||
|
||
function initCopyButtons() {
|
||
document.querySelectorAll('[data-copy]').forEach((element) => {
|
||
element.addEventListener('click', async () => {
|
||
const value = element.getAttribute('data-copy');
|
||
if (!value) return;
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(value);
|
||
showToast('Скопировано: ' + value);
|
||
} catch (error) {
|
||
console.error('Ошибка копирования:', error);
|
||
showToast('Не удалось скопировать');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function initReveal() {
|
||
const revealElements = document.querySelectorAll('.reveal');
|
||
if (!revealElements.length) return;
|
||
|
||
if (!('IntersectionObserver' in window)) {
|
||
revealElements.forEach((element) => element.classList.add('active'));
|
||
return;
|
||
}
|
||
|
||
const observer = new IntersectionObserver((entries) => {
|
||
entries.forEach((entry) => {
|
||
if (entry.isIntersecting) {
|
||
entry.target.classList.add('active');
|
||
observer.unobserve(entry.target);
|
||
}
|
||
});
|
||
}, {
|
||
threshold: 0.08,
|
||
rootMargin: '0px 0px -48px 0px'
|
||
});
|
||
|
||
revealElements.forEach((element) => observer.observe(element));
|
||
}
|
||
|
||
function initNavigationHighlight() {
|
||
const links = Array.from(document.querySelectorAll('.nav-link'));
|
||
const sections = links
|
||
.map((link) => document.querySelector(link.getAttribute('href')))
|
||
.filter(Boolean);
|
||
|
||
if (!links.length || !sections.length) return;
|
||
|
||
const setActiveLink = () => {
|
||
const currentSection = sections.reduce((current, section) => {
|
||
const sectionTop = section.getBoundingClientRect().top;
|
||
if (sectionTop <= 120) {
|
||
return section;
|
||
}
|
||
return current;
|
||
}, sections[0]);
|
||
|
||
links.forEach((link) => {
|
||
const isActive = link.getAttribute('href') === '#' + currentSection.id;
|
||
link.classList.toggle('active', isActive);
|
||
});
|
||
};
|
||
|
||
setActiveLink();
|
||
window.addEventListener('scroll', setActiveLink, { passive: true });
|
||
}
|
||
|
||
function initIncidentTabs() {
|
||
const tabs = document.querySelectorAll('.incident-tab');
|
||
const panel = document.getElementById('incidentPanel');
|
||
if (!tabs.length || !panel) return;
|
||
|
||
const renderIncident = (incidentId) => {
|
||
const runbook = incidentRunbooks[incidentId];
|
||
if (!runbook) return;
|
||
|
||
panel.innerHTML = `
|
||
<h3>${escapeHtml(runbook.title)}</h3>
|
||
<div class="incident-steps">
|
||
${runbook.steps.map((step) => `
|
||
<div class="incident-step">
|
||
<strong>${escapeHtml(step.label)}</strong>
|
||
<p>${formatRunbookText(step.text)}</p>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
};
|
||
|
||
tabs.forEach((tab) => {
|
||
tab.addEventListener('click', () => {
|
||
tabs.forEach((item) => item.classList.remove('active'));
|
||
tab.classList.add('active');
|
||
renderIncident(tab.dataset.incident);
|
||
});
|
||
});
|
||
|
||
renderIncident(document.querySelector('.incident-tab.active')?.dataset.incident || 'postgres');
|
||
}
|
||
|
||
function formatRunbookText(text) {
|
||
return escapeHtml(text).replace(/`([^`]+)`/g, '<code>$1</code>');
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
function showToast(message) {
|
||
const toast = document.getElementById('toast');
|
||
if (!toast) return;
|
||
|
||
toast.textContent = message;
|
||
toast.classList.add('show');
|
||
|
||
window.clearTimeout(window.resumeToastTimer);
|
||
window.resumeToastTimer = window.setTimeout(() => {
|
||
toast.classList.remove('show');
|
||
}, 2400);
|
||
}
|