Files
veeam/backup-checklist.html

823 lines
27 KiB
HTML
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.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Чек-лист администратора резервного копирования</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #14171a;
--surface: #1b2025;
--surface-2: #21272e;
--border: #2d343c;
--text: #e6e9ed;
--text-dim: #97a1ac;
--text-faint: #626b75;
--accent: #4fd1c5;
--accent-dim: #2c7a72;
--warn: #e8a33d;
--warn-dim: #8a611f;
--radius: 3px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: 'IBM Plex Sans', -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
padding: 0 0 80px;
}
.mono { font-family: 'IBM Plex Mono', monospace; }
/* header */
.header {
position: sticky;
top: 0;
z-index: 10;
background: rgba(20,23,26,0.94);
backdrop-filter: blur(6px);
border-bottom: 1px solid var(--border);
padding: 18px 24px 16px;
}
.header-top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
max-width: 760px;
margin: 0 auto;
}
h1 {
font-size: 17px;
font-weight: 600;
margin: 0;
letter-spacing: 0.2px;
}
.subtitle {
color: var(--text-dim);
font-size: 13px;
margin-top: 3px;
}
.overall-count {
font-family: 'IBM Plex Mono', monospace;
font-size: 13px;
color: var(--accent);
white-space: nowrap;
}
.overall-count .skipped-note {
color: var(--text-faint);
font-size: 11.5px;
display: block;
text-align: right;
margin-top: 2px;
}
.progress-track {
max-width: 760px;
margin: 12px auto 0;
height: 3px;
background: var(--surface-2);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
width: 0%;
transition: width 0.4s ease;
}
/* layout */
.wrap {
max-width: 760px;
margin: 28px auto 0;
padding: 0 24px;
}
.section {
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 14px;
background: var(--surface);
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 16px;
cursor: pointer;
user-select: none;
border-left: 2px solid var(--border);
}
.section.crit .section-header { border-left-color: var(--warn-dim); }
.section-header:hover { background: var(--surface-2); }
.section-header-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.freq-tag {
font-family: 'IBM Plex Mono', monospace;
font-size: 10.5px;
letter-spacing: 0.3px;
color: var(--text-dim);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 2px;
padding: 2px 6px;
flex-shrink: 0;
}
.section.crit .freq-tag { color: var(--warn); border-color: var(--warn-dim); }
.section-title {
font-size: 14.5px;
font-weight: 500;
}
.section-header-right {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.section-count {
font-family: 'IBM Plex Mono', monospace;
font-size: 12.5px;
color: var(--text-faint);
}
.section-count.done { color: var(--accent); }
.chevron {
width: 9px;
height: 9px;
border-right: 1.5px solid var(--text-faint);
border-bottom: 1.5px solid var(--text-faint);
transform: rotate(-45deg);
transition: transform 0.2s ease;
margin-right: 2px;
}
.section.open .chevron { transform: rotate(45deg); }
.section-body {
max-height: 0;
overflow: hidden;
transition: max-height 0.25s ease;
}
.section.open .section-body {
max-height: 2000px;
}
.section-body-inner {
padding: 2px 16px 12px;
border-top: 1px solid var(--border);
}
.item {
display: flex;
align-items: flex-start;
gap: 11px;
padding: 11px 0;
border-bottom: 1px solid var(--border);
}
.item:last-child { border-bottom: none; }
.checkbox {
width: 16px;
height: 16px;
border: 1.5px solid var(--text-faint);
border-radius: 3px;
flex-shrink: 0;
margin-top: 2px;
cursor: pointer;
position: relative;
transition: border-color 0.15s ease, background 0.15s ease, opacity 0.15s ease;
}
.checkbox:hover { border-color: var(--accent); }
.checkbox.checked {
background: var(--accent);
border-color: var(--accent);
}
.checkbox.checked::after {
content: '';
position: absolute;
left: 4px;
top: 1px;
width: 4px;
height: 8px;
border-right: 2px solid #0e1214;
border-bottom: 2px solid #0e1214;
transform: rotate(40deg);
}
.checkbox.skipped {
opacity: 0.35;
cursor: default;
border-style: dashed;
}
.item-text { flex: 1; min-width: 0; }
.item-title-row {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.item-title {
font-size: 13.8px;
line-height: 1.4;
cursor: pointer;
transition: color 0.15s ease;
}
.item.done .item-title {
color: var(--text-faint);
text-decoration: line-through;
text-decoration-color: var(--text-faint);
}
.item.skipped .item-title {
color: var(--text-faint);
}
.na-badge {
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
letter-spacing: 0.3px;
color: var(--text-faint);
border: 1px solid var(--border);
border-radius: 2px;
padding: 1px 5px;
flex-shrink: 0;
}
.item-desc {
font-size: 12.5px;
color: var(--text-dim);
line-height: 1.5;
margin-top: 3px;
}
.item.done .item-desc,
.item.skipped .item-desc { color: var(--text-faint); }
.skip-toggle {
background: none;
border: none;
color: var(--text-faint);
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
cursor: pointer;
padding: 2px 0;
flex-shrink: 0;
align-self: flex-start;
margin-top: 2px;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
.skip-toggle:hover { color: var(--text-dim); }
.item.skipped .skip-toggle { color: var(--warn); }
.reset-btn {
background: none;
border: 1px solid var(--border);
color: var(--text-faint);
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
padding: 3px 8px;
border-radius: 2px;
cursor: pointer;
margin-top: 8px;
}
.reset-btn:hover { color: var(--text-dim); border-color: var(--text-dim); }
.footer-bar {
max-width: 760px;
margin: 20px auto 0;
padding: 0 24px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.footer-note {
font-size: 11.5px;
color: var(--text-faint);
}
.footer-actions { display: flex; gap: 8px; }
.btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-dim);
font-family: 'IBM Plex Mono', monospace;
font-size: 11.5px;
padding: 6px 11px;
border-radius: 2px;
cursor: pointer;
}
.btn:hover { border-color: var(--text-dim); color: var(--text); }
.btn.warn-hover:hover { border-color: var(--warn-dim); color: var(--warn); }
.btn.accent {
border-color: var(--accent-dim);
color: var(--accent);
}
.btn.accent:hover { border-color: var(--accent); background: rgba(79,209,197,0.08); }
.loading {
text-align: center;
color: var(--text-faint);
font-size: 13px;
padding: 40px 0;
font-family: 'IBM Plex Mono', monospace;
}
/* report modal */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(10,12,14,0.7);
z-index: 50;
padding: 24px;
overflow-y: auto;
}
.modal-overlay.open { display: block; }
.modal {
max-width: 640px;
margin: 20px auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
font-size: 14.5px;
font-weight: 600;
margin: 0;
}
.modal-close {
background: none;
border: none;
color: var(--text-faint);
font-size: 18px;
line-height: 1;
cursor: pointer;
padding: 2px 4px;
}
.modal-close:hover { color: var(--text); }
.report-text {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
line-height: 1.6;
color: var(--text);
white-space: pre-wrap;
padding: 16px 18px;
max-height: 55vh;
overflow-y: auto;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 18px 16px;
border-top: 1px solid var(--border);
}
.copy-confirm {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: var(--accent);
align-self: center;
margin-right: auto;
opacity: 0;
transition: opacity 0.2s ease;
}
.copy-confirm.show { opacity: 1; }
@media print {
body * { visibility: hidden; }
.report-text, .report-text * { visibility: visible; }
.report-text {
position: absolute;
left: 0; top: 0;
width: 100%;
max-height: none;
}
}
</style>
</head>
<body>
<div class="header">
<div class="header-top">
<div>
<h1>Чек-лист резервного копирования</h1>
<div class="subtitle">Регламентные проверки и восстановление</div>
</div>
<div class="overall-count mono" id="overallCount">0 / 0</div>
</div>
<div class="progress-track"><div class="progress-fill" id="overallFill"></div></div>
</div>
<div class="wrap" id="sections">
<div class="loading">Загрузка…</div>
</div>
<div class="footer-bar">
<div class="footer-note">Прогресс сохраняется автоматически</div>
<div class="footer-actions">
<button class="btn accent" id="reportBtn">Отчёт для ППР</button>
<button class="btn warn-hover" id="resetAllBtn">Сбросить всё</button>
</div>
</div>
<div class="modal-overlay" id="modalOverlay">
<div class="modal">
<div class="modal-header">
<h2>Отчёт по плановому обслуживанию</h2>
<button class="modal-close" id="modalClose">&times;</button>
</div>
<div class="report-text" id="reportText"></div>
<div class="modal-footer">
<span class="copy-confirm" id="copyConfirm">Скопировано</span>
<button class="btn" id="printBtn">Печать</button>
<button class="btn accent" id="copyBtn">Копировать</button>
</div>
</div>
</div>
<script>
const DATA = [
{
id: 'daily',
title: 'Ежедневные проверки',
freq: 'ЕЖЕДНЕВНО',
crit: false,
items: [
{ id: 'd1', title: 'Проверить статус всех ночных заданий бэкапа', desc: 'Убедиться, что все задания завершились успешно, отметить сбойные и зависшие.' },
{ id: 'd2', title: 'Просмотреть журналы на ошибки и предупреждения', desc: 'Логи агентов резервного копирования и сервера управления.' },
{ id: 'd3', title: 'Проверить свободное место в хранилище', desc: 'Дисковые пулы, дедупликация, ленточная библиотека, облачный бакет.' },
{ id: 'd4', title: 'Проверить репликацию в offsite / DR-хранилище', desc: 'Убедиться, что копии доехали до второй площадки без задержек.' },
{ id: 'd5', title: 'Разобрать алерты системы мониторинга', desc: 'Закрыть или эскалировать открытые инциденты за сутки.' }
]
},
{
id: 'weekly',
title: 'Еженедельные проверки',
freq: 'ЕЖЕНЕДЕЛЬНО',
crit: false,
items: [
{ id: 'w1', title: 'Выполнить тестовое восстановление отдельного файла', desc: 'Случайная система из списка защищаемых — проверка "по кругу".' },
{ id: 'w2', title: 'Проверить целостность бэкапов (verify / checksum)', desc: 'Запустить встроенную проверку целостности, если не выполняется автоматически.' },
{ id: 'w3', title: 'Проверить ротацию носителей', desc: 'Ленты, снапшоты, архивные копии — соответствие схеме ротации.' },
{ id: 'w4', title: 'Проверить окна резервного копирования', desc: 'Нет ли пересечений заданий и превышения бэкап-окна.' },
{ id: 'w5', title: 'Актуализировать документацию по изменениям', desc: 'Новые системы, изменённые расписания, изменения в политике.' }
]
},
{
id: 'monthly',
title: 'Ежемесячные проверки',
freq: 'ЕЖЕМЕСЯЧНО',
crit: false,
items: [
{ id: 'm1', title: 'Полное тестовое восстановление одной системы', desc: 'Восстановление в изолированную среду с проверкой работоспособности.' },
{ id: 'm2', title: 'Сверить соответствие RPO/RTO для критичных систем', desc: 'Сравнить фактические показатели с требованиями бизнеса.' },
{ id: 'm3', title: 'Аудит списка защищаемых систем', desc: 'Найти новые серверы и сервисы, не включённые в бэкап.' },
{ id: 'm4', title: 'Проверить сроки лицензий и сертификатов ПО', desc: 'Система резервного копирования, агенты, хранилище.' },
{ id: 'm5', title: 'Очистка устаревших копий по политике хранения', desc: 'Освободить место согласно retention policy, зафиксировать удаление.' }
]
},
{
id: 'quarterly',
title: 'Ежеквартальные проверки',
freq: 'ЕЖЕКВАРТАЛЬНО',
crit: false,
items: [
{ id: 'q1', title: 'Полноценные учения по аварийному восстановлению (DR drill)', desc: 'С участием ответственных команд, по заранее утверждённому сценарию.' },
{ id: 'q2', title: 'Пересмотреть политику резервного копирования', desc: 'Актуальность расписаний, retention, приоритетов систем.' },
{ id: 'q3', title: 'Проверить offsite / облачное хранилище на соответствие требованиям', desc: 'Договор, гео-репликация, требования регуляторов при наличии.' },
{ id: 'q4', title: 'Обновить контакты и процедуру эскалации', desc: 'Актуальный список ответственных и порядок оповещения при инциденте.' },
{ id: 'q5', title: 'Проверить шифрование бэкапов и управление ключами', desc: 'Ротация ключей, доступ к хранилищу ключей, резервная копия ключей.' }
]
},
{
id: 'incident',
title: 'При инциденте потери данных',
freq: 'ПО СОБЫТИЮ',
crit: true,
items: [
{ id: 'i1', title: 'Зафиксировать время и масштаб инцидента', desc: 'Какие системы затронуты, с какого момента.' },
{ id: 'i2', title: 'Определить последнюю валидную точку восстановления', desc: 'Проверить, что копия прошла верификацию целостности.' },
{ id: 'i3', title: 'Уведомить заинтересованные стороны', desc: 'По утверждённой процедуре эскалации.' },
{ id: 'i4', title: 'Восстановить в изолированной среде для проверки', desc: 'Не восстанавливать сразу в прод без подтверждения целостности.' },
{ id: 'i5', title: 'Восстановить в продакшн после подтверждения', desc: 'С контролем на этапе переключения и наблюдением после.' },
{ id: 'i6', title: 'Задокументировать инцидент (post-mortem)', desc: 'Причина, хронология, план предотвращения повторения.' }
]
},
{
id: 'onboarding',
title: 'Подключение новой системы',
freq: 'ПО СОБЫТИЮ',
crit: false,
items: [
{ id: 'o1', title: 'Определить критичность системы (RPO/RTO)', desc: 'Согласовать требования с владельцем системы.' },
{ id: 'o2', title: 'Настроить задание резервного копирования', desc: 'Расписание, тип бэкапа, место хранения.' },
{ id: 'o3', title: 'Проверить первое полное резервное копирование', desc: 'Убедиться в успешном завершении и корректном объёме данных.' },
{ id: 'o4', title: 'Выполнить тестовое восстановление', desc: 'Подтвердить, что данные реально восстанавливаются.' },
{ id: 'o5', title: 'Добавить систему в мониторинг и документацию', desc: 'Алерты на сбой задания, запись в реестре защищаемых систем.' }
]
}
];
// state[itemId] = 'done' | 'skipped' | undefined (pending)
const STORAGE_KEY = 'backup-checklist-state-v2';
let state = {};
let openSections = new Set(['daily']);
async function loadState() {
try {
const result = await window.storage.get(STORAGE_KEY, false);
if (result && result.value) {
state = JSON.parse(result.value);
}
} catch (e) {
state = {};
}
}
async function saveState() {
try {
await window.storage.set(STORAGE_KEY, JSON.stringify(state), false);
} catch (e) {
console.error('Не удалось сохранить состояние', e);
}
}
function sectionStats(section) {
const total = section.items.length;
const skipped = section.items.filter(it => state[it.id] === 'skipped').length;
const done = section.items.filter(it => state[it.id] === 'done').length;
const applicable = total - skipped;
return { total, skipped, done, applicable };
}
function render() {
const container = document.getElementById('sections');
container.innerHTML = '';
let totalDone = 0, totalApplicable = 0, totalSkipped = 0;
DATA.forEach(section => {
const stats = sectionStats(section);
totalDone += stats.done;
totalApplicable += stats.applicable;
totalSkipped += stats.skipped;
const isOpen = openSections.has(section.id);
const secEl = document.createElement('div');
secEl.className = 'section' + (section.crit ? ' crit' : '') + (isOpen ? ' open' : '');
secEl.innerHTML = `
<div class="section-header" data-section="${section.id}">
<div class="section-header-left">
<span class="chevron"></span>
<span class="freq-tag">${section.freq}</span>
<span class="section-title">${section.title}</span>
</div>
<div class="section-header-right">
<span class="section-count${stats.applicable > 0 && stats.done === stats.applicable ? ' done' : ''}">${stats.done}/${stats.applicable}${stats.skipped ? ` <span class="mono">(н/д ${stats.skipped})</span>` : ''}</span>
</div>
</div>
<div class="section-body">
<div class="section-body-inner">
${section.items.map(it => {
const st = state[it.id];
const isDone = st === 'done';
const isSkipped = st === 'skipped';
return `
<div class="item${isDone ? ' done' : ''}${isSkipped ? ' skipped' : ''}" data-item="${it.id}">
<div class="checkbox${isDone ? ' checked' : ''}${isSkipped ? ' skipped' : ''}" data-item-cb="${it.id}"></div>
<div class="item-text">
<div class="item-title-row">
<div class="item-title" data-item-title="${it.id}">${it.title}</div>
${isSkipped ? '<span class="na-badge">Н/Д</span>' : ''}
</div>
<div class="item-desc">${it.desc}</div>
<button class="skip-toggle" data-skip="${it.id}">${isSkipped ? 'вернуть в список' : 'нет в этой инсталляции'}</button>
</div>
</div>
`;
}).join('')}
<button class="reset-btn" data-reset-section="${section.id}">Сбросить раздел</button>
</div>
</div>
`;
container.appendChild(secEl);
});
const overallCountEl = document.getElementById('overallCount');
overallCountEl.innerHTML = `${totalDone} / ${totalApplicable}` + (totalSkipped ? `<span class="skipped-note">н/д: ${totalSkipped}</span>` : '');
document.getElementById('overallFill').style.width = totalApplicable ? `${(totalDone / totalApplicable) * 100}%` : '0%';
container.querySelectorAll('.section-header').forEach(el => {
el.addEventListener('click', () => {
const id = el.dataset.section;
if (openSections.has(id)) openSections.delete(id);
else openSections.add(id);
render();
});
});
container.querySelectorAll('[data-item-cb], [data-item-title]').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const id = el.dataset.itemCb || el.dataset.itemTitle;
if (state[id] === 'skipped') return; // must un-skip via the skip toggle first
state[id] = state[id] === 'done' ? undefined : 'done';
if (state[id] === undefined) delete state[id];
saveState();
render();
});
});
container.querySelectorAll('[data-skip]').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const id = el.dataset.skip;
if (state[id] === 'skipped') {
delete state[id];
} else {
state[id] = 'skipped';
}
saveState();
render();
});
});
container.querySelectorAll('[data-reset-section]').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const section = DATA.find(s => s.id === el.dataset.resetSection);
section.items.forEach(it => { delete state[it.id]; });
saveState();
render();
});
});
}
document.getElementById('resetAllBtn').addEventListener('click', () => {
state = {};
saveState();
render();
});
// ---- Report for ППР ----
function buildReport() {
const now = new Date();
const dateStr = now.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
const timeStr = now.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
let lines = [];
lines.push('ОТЧЁТ О ПЛАНОВОМ ОБСЛУЖИВАНИИ СИСТЕМЫ РЕЗЕРВНОГО КОПИРОВАНИЯ');
lines.push(`Дата: ${dateStr} Время: ${timeStr}`);
lines.push('='.repeat(58));
let totalDone = 0, totalApplicable = 0, totalSkipped = 0, totalOpen = 0;
const openItemsList = [];
const skippedItemsList = [];
DATA.forEach(section => {
const stats = sectionStats(section);
totalDone += stats.done;
totalApplicable += stats.applicable;
totalSkipped += stats.skipped;
lines.push('');
lines.push(`${section.title} [${section.freq}]`);
lines.push(` Выполнено: ${stats.done}/${stats.applicable}` + (stats.skipped ? ` Не применимо: ${stats.skipped}` : ''));
section.items.forEach(it => {
const st = state[it.id];
let mark = '[ ]';
if (st === 'done') mark = '[x]';
else if (st === 'skipped') mark = '[—]';
lines.push(` ${mark} ${it.title}`);
if (st === 'skipped') skippedItemsList.push(`${section.title}: ${it.title}`);
if (!st) { totalOpen++; openItemsList.push(`${section.title}: ${it.title}`); }
});
});
lines.push('');
lines.push('='.repeat(58));
lines.push('ИТОГО');
const pct = totalApplicable ? Math.round((totalDone / totalApplicable) * 100) : 0;
lines.push(` Выполнено: ${totalDone} из ${totalApplicable} применимых пунктов (${pct}%)`);
lines.push(` Не применимо к инсталляции: ${totalSkipped}`);
lines.push(` Осталось выполнить: ${totalOpen}`);
if (openItemsList.length) {
lines.push('');
lines.push('Невыполненные пункты:');
openItemsList.forEach(t => lines.push(` - ${t}`));
}
if (skippedItemsList.length) {
lines.push('');
lines.push('Пункты, отмеченные как не применимые (Н/Д):');
skippedItemsList.forEach(t => lines.push(` - ${t}`));
}
lines.push('');
lines.push('-'.repeat(58));
lines.push('Ответственный: _____________________ Подпись: _____________');
return lines.join('\n');
}
const modalOverlay = document.getElementById('modalOverlay');
document.getElementById('reportBtn').addEventListener('click', () => {
document.getElementById('reportText').textContent = buildReport();
modalOverlay.classList.add('open');
});
document.getElementById('modalClose').addEventListener('click', () => modalOverlay.classList.remove('open'));
modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) modalOverlay.classList.remove('open'); });
document.getElementById('printBtn').addEventListener('click', () => window.print());
document.getElementById('copyBtn').addEventListener('click', async () => {
const text = document.getElementById('reportText').textContent;
try {
await navigator.clipboard.writeText(text);
} catch (e) {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
const confirmEl = document.getElementById('copyConfirm');
confirmEl.classList.add('show');
setTimeout(() => confirmEl.classList.remove('show'), 1500);
});
(async function init() {
await loadState();
render();
})();
</script>
</body>
</html>