/** * app.js — Firewall Rules Builder UI */ // ─── Глобальное состояние ──────────────────────────────────────────────────── const State = { objects: {}, // servers + nets groups: {}, services: {}, service_groups: {}, rules: [], }; // ─── Утилиты ───────────────────────────────────────────────────────────────── function showToast(msg, type = 'success') { const container = document.getElementById('toast-container'); const id = 'toast-' + Date.now(); const bg = type === 'success' ? 'bg-success' : type === 'danger' ? 'bg-danger' : 'bg-warning'; container.insertAdjacentHTML('beforeend', ` `); const el = document.getElementById(id); const t = new bootstrap.Toast(el, { delay: 3000 }); t.show(); el.addEventListener('hidden.bs.toast', () => el.remove()); } function showError(elId, msg) { const el = document.getElementById(elId); if (el) { el.textContent = msg; el.classList.remove('d-none'); } } function hideError(elId) { const el = document.getElementById(elId); if (el) el.classList.add('d-none'); } function parseAffinity(str) { return str.split(',').map(s => s.trim()).filter(Boolean); } function affinityStr(arr) { return Array.isArray(arr) ? arr.join(', ') : (arr || ''); } async function api(method, url, body) { const opts = { method, headers: { 'Content-Type': 'application/json' } }; if (body !== undefined) opts.body = JSON.stringify(body); const r = await fetch(url, opts); return r.json(); } function escHtml(s) { return String(s || '').replace(/&/g,'&').replace(//g,'>'); } // ─── Загрузка всех данных ───────────────────────────────────────────────────── async function loadAll() { const data = await api('GET', '/api/all'); State.objects = {}; for (const [k, v] of Object.entries(data.servers || {})) State.objects[k] = v; for (const [k, v] of Object.entries(data.nets || {})) State.objects[k] = v; State.groups = data.groups || {}; State.services = data.services || {}; State.service_groups = data.service_groups || {}; State.rules = data.rules || []; } function renderAll() { Objects.render(); Groups.render(); Services.render(); SvcGroups.render(); Rules.render(); } // ─── App (загрузка файла, сброс) ───────────────────────────────────────────── const App = { async uploadFile(input) { const file = input.files[0]; if (!file) return; const formData = new FormData(); formData.append('file', file); try { const resp = await fetch('/api/upload', { method: 'POST', body: formData }); const r = await resp.json(); if (r.ok) { showToast('Файл загружен и импортирован успешно'); await loadAll(); renderAll(); } else { showToast('Ошибка импорта: ' + (r.error || ''), 'danger'); } } catch (e) { showToast('Ошибка: ' + e.message, 'danger'); } // Сбрасываем input чтобы можно было загрузить тот же файл повторно input.value = ''; }, async resetAll() { if (!confirm('Сбросить ВСЕ данные? Это действие необратимо.')) return; const r = await api('POST', '/api/reset'); if (r.ok) { showToast('Все данные сброшены', 'warning'); await loadAll(); renderAll(); } } }; // ═══════════════════════════════════════════════════════════════════════════════ // OBJECTS // ═══════════════════════════════════════════════════════════════════════════════ const Objects = { modal: null, _filter: '', _typeFilter: '', init() { this.modal = new bootstrap.Modal(document.getElementById('modal-object')); }, render() { const tbody = document.getElementById('objects-tbody'); const q = this._filter.toLowerCase(); const tf = this._typeFilter; const rows = []; for (const [key, obj] of Object.entries(State.objects)) { if (q && !key.toLowerCase().includes(q) && !(obj.description || '').toLowerCase().includes(q) && !(obj.ip || '').includes(q)) continue; if (tf && obj.type !== tf) continue; const typeBadge = obj.type === 'network' ? `network` : `host`; rows.push(` ${escHtml(key)} ${typeBadge} ${escHtml(obj.ip)} ${escHtml(obj.prefix)} ${escHtml(obj.gw)} ${escHtml(obj.domain)} ${escHtml(obj.description)} ${escHtml(affinityStr(obj.affinity))} `); } tbody.innerHTML = rows.join('') || 'Нет объектов'; }, filter(val) { if (val !== undefined) this._filter = val; this._typeFilter = document.getElementById('obj-type-filter').value; this.render(); }, openModal(key) { hideError('obj-error'); const isEdit = !!key; document.getElementById('modal-object-title').textContent = isEdit ? 'Редактировать объект' : 'Новый объект'; document.getElementById('obj-edit-key').value = key || ''; document.getElementById('obj-key').disabled = isEdit; if (isEdit) { const obj = State.objects[key]; document.getElementById('obj-key').value = key; document.getElementById('obj-hostname').value = obj.hostname || ''; document.getElementById('obj-type').value = obj.type || 'host'; document.getElementById('obj-ip').value = obj.ip || ''; document.getElementById('obj-prefix').value = obj.prefix || '24'; document.getElementById('obj-gw').value = obj.gw || ''; document.getElementById('obj-domain').value = obj.domain || ''; document.getElementById('obj-description').value = obj.description || ''; document.getElementById('obj-affinity').value = affinityStr(obj.affinity); } else { document.getElementById('form-object').reset(); document.getElementById('obj-key').disabled = false; } this.modal.show(); }, async save() { hideError('obj-error'); const editKey = document.getElementById('obj-edit-key').value; const key = editKey || document.getElementById('obj-key').value.trim(); if (!key) { showError('obj-error', 'Ключ обязателен'); return; } const payload = { key, hostname: document.getElementById('obj-hostname').value.trim() || key, type: document.getElementById('obj-type').value, ip: document.getElementById('obj-ip').value.trim(), prefix: document.getElementById('obj-prefix').value.trim() || '24', gw: document.getElementById('obj-gw').value.trim(), domain: document.getElementById('obj-domain').value.trim(), description: document.getElementById('obj-description').value.trim(), affinity: parseAffinity(document.getElementById('obj-affinity').value), }; let r; if (editKey) { r = await api('PUT', `/api/objects/${encodeURIComponent(editKey)}`, payload); } else { r = await api('POST', '/api/objects', payload); } if (r.ok) { showToast(editKey ? 'Объект обновлён' : 'Объект создан'); this.modal.hide(); await loadAll(); this.render(); } else { showError('obj-error', r.error || 'Ошибка'); } }, async delete(key) { const r = await api('DELETE', `/api/objects/${encodeURIComponent(key)}`); if (r.ok) { showToast('Объект удалён', 'warning'); await loadAll(); this.render(); // Обновляем группы и правила — там могут быть ссылки на удалённый объект Groups.render(); Rules.render(); } } }; // ═══════════════════════════════════════════════════════════════════════════════ // GROUPS // ═══════════════════════════════════════════════════════════════════════════════ const Groups = { modal: null, _selectedItems: [], // [{hostname}] _filter: '', init() { this.modal = new bootstrap.Modal(document.getElementById('modal-group')); }, render() { const tbody = document.getElementById('groups-tbody'); const q = this._filter.toLowerCase(); const rows = []; for (const [key, grp] of Object.entries(State.groups)) { if (q && !key.toLowerCase().includes(q) && !(grp.name || '').toLowerCase().includes(q)) continue; const items = (grp.items || []).map(i => { const exists = !!State.objects[i.hostname]; const cls = exists ? 'cell-tag-group' : 'cell-tag-missing'; const icon = exists ? '' : ''; return `${icon}${escHtml(i.hostname)}`; }).join(' '); rows.push(` ${escHtml(key)} ${escHtml(grp.name)}
${items || ''}
`); } tbody.innerHTML = rows.join('') || 'Нет групп'; }, filter(val) { if (val !== undefined) this._filter = val; this.render(); }, openModal(key) { hideError('grp-error'); const isEdit = !!key; document.getElementById('modal-group-title').textContent = isEdit ? 'Редактировать группу' : 'Новая группа'; document.getElementById('grp-edit-key').value = key || ''; document.getElementById('grp-key').disabled = isEdit; document.getElementById('grp-item-search').value = ''; document.getElementById('grp-item-type-filter').value = ''; if (isEdit) { const grp = State.groups[key]; document.getElementById('grp-key').value = key; document.getElementById('grp-name').value = grp.name || ''; this._selectedItems = [...(grp.items || [])]; } else { document.getElementById('form-group').reset(); document.getElementById('grp-key').disabled = false; this._selectedItems = []; } this.renderPicker(); this.modal.show(); }, filterItems() { this.renderPicker(); }, renderPicker() { const q = (document.getElementById('grp-item-search').value || '').toLowerCase(); const tf = document.getElementById('grp-item-type-filter').value; const selectedHostnames = new Set(this._selectedItems.map(i => i.hostname)); // Доступные const avail = document.getElementById('grp-available-list'); const availItems = []; for (const [key, obj] of Object.entries(State.objects)) { if (selectedHostnames.has(key)) continue; if (q && !key.toLowerCase().includes(q) && !(obj.description || '').toLowerCase().includes(q)) continue; if (tf && obj.type !== tf) continue; const badge = obj.type === 'network' ? `net` : `host`; availItems.push(`
${escHtml(key)} ${badge}
`); } avail.innerHTML = availItems.join('') || '
Нет объектов
'; // Выбранные const sel = document.getElementById('grp-selected-list'); const selItems = this._selectedItems.map((item, idx) => { const obj = State.objects[item.hostname]; const badge = obj ? (obj.type === 'network' ? `net` : `host`) : `удалён`; const cls = obj ? '' : 'picker-item-missing'; return `
${obj ? '' : ''}${escHtml(item.hostname)} ${badge}
`; }); sel.innerHTML = selItems.join('') || '
Ничего не выбрано
'; document.getElementById('grp-selected-count').textContent = this._selectedItems.length; }, addItem(hostname) { if (!this._selectedItems.find(i => i.hostname === hostname)) { this._selectedItems.push({ hostname }); } this.renderPicker(); }, removeItem(idx) { this._selectedItems.splice(idx, 1); this.renderPicker(); }, async save() { hideError('grp-error'); const editKey = document.getElementById('grp-edit-key').value; const key = editKey || document.getElementById('grp-key').value.trim(); if (!key) { showError('grp-error', 'Ключ обязателен'); return; } const payload = { key, name: document.getElementById('grp-name').value.trim() || key, items: this._selectedItems, }; let r; if (editKey) { r = await api('PUT', `/api/groups/${encodeURIComponent(editKey)}`, payload); } else { r = await api('POST', '/api/groups', payload); } if (r.ok) { showToast(editKey ? 'Группа обновлена' : 'Группа создана'); this.modal.hide(); await loadAll(); this.render(); } else { showError('grp-error', r.error || 'Ошибка'); } }, async delete(key) { const r = await api('DELETE', `/api/groups/${encodeURIComponent(key)}`); if (r.ok) { showToast('Группа удалена', 'warning'); await loadAll(); this.render(); Rules.render(); } } }; // ═══════════════════════════════════════════════════════════════════════════════ // SERVICES // ═══════════════════════════════════════════════════════════════════════════════ const Services = { modal: null, _filter: '', _protoFilter: '', init() { this.modal = new bootstrap.Modal(document.getElementById('modal-service')); }, render() { const tbody = document.getElementById('services-tbody'); const q = this._filter.toLowerCase(); const pf = this._protoFilter; const rows = []; for (const [key, svc] of Object.entries(State.services)) { if (q && !key.toLowerCase().includes(q) && !(svc.name || '').toLowerCase().includes(q) && !(svc.dport || '').includes(q)) continue; if (pf && svc.proto !== pf) continue; const protoBadge = `${escHtml(svc.proto)}`; rows.push(` ${escHtml(key)} ${escHtml(svc.name)} ${protoBadge} ${escHtml(svc.sport)} ${escHtml(svc.dport)} `); } tbody.innerHTML = rows.join('') || 'Нет сервисов'; }, filter(val) { if (val !== undefined) this._filter = val; this._protoFilter = document.getElementById('svc-proto-filter').value; this.render(); }, openModal(key) { hideError('svc-error'); const isEdit = !!key; document.getElementById('modal-service-title').textContent = isEdit ? 'Редактировать сервис' : 'Новый сервис'; document.getElementById('svc-edit-key').value = key || ''; document.getElementById('svc-key').disabled = isEdit; if (isEdit) { const svc = State.services[key]; document.getElementById('svc-key').value = key; document.getElementById('svc-name').value = svc.name || ''; document.getElementById('svc-proto').value = svc.proto || 'tcp'; document.getElementById('svc-sport').value = svc.sport || 'any'; document.getElementById('svc-dport').value = svc.dport || ''; } else { document.getElementById('form-service').reset(); document.getElementById('svc-key').disabled = false; document.getElementById('svc-sport').value = 'any'; } this.modal.show(); }, async save() { hideError('svc-error'); const editKey = document.getElementById('svc-edit-key').value; const key = editKey || document.getElementById('svc-key').value.trim(); if (!key) { showError('svc-error', 'Ключ обязателен'); return; } const payload = { key, name: document.getElementById('svc-name').value.trim() || key, proto: document.getElementById('svc-proto').value, sport: document.getElementById('svc-sport').value.trim() || 'any', dport: document.getElementById('svc-dport').value.trim(), }; let r; if (editKey) { r = await api('PUT', `/api/services/${encodeURIComponent(editKey)}`, payload); } else { r = await api('POST', '/api/services', payload); } if (r.ok) { showToast(editKey ? 'Сервис обновлён' : 'Сервис создан'); this.modal.hide(); await loadAll(); this.render(); } else { showError('svc-error', r.error || 'Ошибка'); } }, async delete(key) { const r = await api('DELETE', `/api/services/${encodeURIComponent(key)}`); if (r.ok) { showToast('Сервис удалён', 'warning'); await loadAll(); this.render(); SvcGroups.render(); Rules.render(); } } }; // ═══════════════════════════════════════════════════════════════════════════════ // SERVICE GROUPS // ═══════════════════════════════════════════════════════════════════════════════ const SvcGroups = { modal: null, _selectedItems: [], // [svc_key] init() { this.modal = new bootstrap.Modal(document.getElementById('modal-svcgroup')); }, render() { const tbody = document.getElementById('svcgroups-tbody'); const rows = []; for (const [key, sg] of Object.entries(State.service_groups)) { const items = (sg.items || []).map(svcKey => { const svc = State.services[svcKey]; const exists = !!svc; const label = exists ? `${svcKey} (${svc.proto}:${svc.dport})` : svcKey; const cls = exists ? 'cell-tag-sgr' : 'cell-tag-missing'; const icon = exists ? '' : ''; return `${icon}${escHtml(label)}`; }).join(' '); rows.push(` ${escHtml(key)} ${escHtml(sg.name)}
${items || ''}
`); } tbody.innerHTML = rows.join('') || 'Нет групп сервисов'; }, openModal(key) { hideError('sg-error'); const isEdit = !!key; document.getElementById('modal-svcgroup-title').textContent = isEdit ? 'Редактировать группу сервисов' : 'Новая группа сервисов'; document.getElementById('sg-edit-key').value = key || ''; document.getElementById('sg-key').disabled = isEdit; document.getElementById('sg-svc-search').value = ''; if (isEdit) { const sg = State.service_groups[key]; document.getElementById('sg-key').value = key; document.getElementById('sg-name').value = sg.name || ''; this._selectedItems = [...(sg.items || [])]; } else { document.getElementById('form-svcgroup').reset(); document.getElementById('sg-key').disabled = false; this._selectedItems = []; } this.renderPicker(); this.modal.show(); }, filterItems(val) { this.renderPicker(val); }, renderPicker(q) { if (q === undefined) q = document.getElementById('sg-svc-search').value || ''; q = q.toLowerCase(); const selectedSet = new Set(this._selectedItems); const avail = document.getElementById('sg-available-list'); const availItems = []; for (const [key, svc] of Object.entries(State.services)) { if (selectedSet.has(key)) continue; if (q && !key.toLowerCase().includes(q) && !(svc.name || '').toLowerCase().includes(q) && !(svc.dport || '').includes(q)) continue; availItems.push(`
${escHtml(key)} ${escHtml(svc.proto)} ${escHtml(svc.dport)}
`); } avail.innerHTML = availItems.join('') || '
Нет сервисов
'; const sel = document.getElementById('sg-selected-list'); const selItems = this._selectedItems.map((svcKey, idx) => { const svc = State.services[svcKey]; const exists = !!svc; const label = exists ? `${svcKey} (${svc.proto}:${svc.dport})` : svcKey; const cls = exists ? '' : 'picker-item-missing'; return `
${exists ? '' : ''}${escHtml(label)} ${exists ? '' : 'удалён'}
`; }); sel.innerHTML = selItems.join('') || '
Ничего не выбрано
'; document.getElementById('sg-selected-count').textContent = this._selectedItems.length; }, addItem(key) { if (!this._selectedItems.includes(key)) this._selectedItems.push(key); this.renderPicker(); }, removeItem(idx) { this._selectedItems.splice(idx, 1); this.renderPicker(); }, async save() { hideError('sg-error'); const editKey = document.getElementById('sg-edit-key').value; const key = editKey || document.getElementById('sg-key').value.trim(); if (!key) { showError('sg-error', 'Ключ обязателен'); return; } const payload = { key, name: document.getElementById('sg-name').value.trim() || key, items: this._selectedItems, }; let r; if (editKey) { r = await api('PUT', `/api/service_groups/${encodeURIComponent(editKey)}`, payload); } else { r = await api('POST', '/api/service_groups', payload); } if (r.ok) { showToast(editKey ? 'Группа сервисов обновлена' : 'Группа сервисов создана'); this.modal.hide(); await loadAll(); this.render(); } else { showError('sg-error', r.error || 'Ошибка'); } }, async delete(key) { const r = await api('DELETE', `/api/service_groups/${encodeURIComponent(key)}`); if (r.ok) { showToast('Группа сервисов удалена', 'warning'); await loadAll(); this.render(); Rules.render(); } } }; // ═══════════════════════════════════════════════════════════════════════════════ // RULES // ═══════════════════════════════════════════════════════════════════════════════ const Rules = { modal: null, spanModal: null, _srcSelected: [], // [{ref_type, ref_key}] _dstSelected: [], _svcSelected: [], // [{ref_type: 'svc'|'sg', ref_key}] init() { this.modal = new bootstrap.Modal(document.getElementById('modal-rule')); this.spanModal = new bootstrap.Modal(document.getElementById('modal-span')); }, // ─── Пересчёт order по позиции ──────────────────────────────────────────── _recalcOrders(rules) { // Назначаем order кратно 10 по позиции в массиве return rules.map((rule, idx) => ({ ...rule, order: (idx + 1) * 10 })); }, // ─── Рендер таблицы правил ───────────────────────────────────────────────── render() { const tbody = document.getElementById('rules-tbody'); const rows = []; let ruleNum = 0; State.rules.forEach((rule, idx) => { if (rule.type === 'span') { rows.push(` ${escHtml(rule.name)} ${escHtml(affinityStr(rule.affinity))} `); return; } ruleNum++; const action = rule.action || 'allow'; const rowClass = `rule-${action}`; const srcTags = this._renderRefList(rule.src_list || []); const dstTags = this._renderRefList(rule.dst_list || []); const svcTags = this._renderSvcList(rule.service_list || [], rule.service_group_list || []); const actionBadge = `${escHtml(action)}`; rows.push(` ${ruleNum} ${escHtml(rule.order || '')} ${escHtml(rule.name)} ${escHtml(rule.description || '')}
${srcTags}
${dstTags}
${svcTags}
${actionBadge} ${escHtml(rule.log || '')} ${escHtml(rule.idp || '')} ${escHtml(affinityStr(rule.affinity))} `); }); tbody.innerHTML = rows.join('') || 'Нет правил'; this._initDragDrop(); }, // ─── Рендер ссылок (src/dst) с проверкой существования ─────────────────── _renderRefList(list) { return (list || []).map(item => { const rt = item.ref_type || 'group'; const rk = item.ref_key || ''; let exists = true; let cls = 'cell-tag-group'; let label = rk; if (rt === 'server') { exists = !!State.objects[rk]; cls = exists ? 'cell-tag-server' : 'cell-tag-missing'; const o = State.objects[rk]; label = rk + (o ? ` (${o.ip})` : ''); } else if (rt === 'net') { exists = !!State.objects[rk]; cls = exists ? 'cell-tag-net' : 'cell-tag-missing'; const o = State.objects[rk]; label = rk + (o ? ` (${o.ip}/${o.prefix})` : ''); } else { exists = !!State.groups[rk]; cls = exists ? 'cell-tag-group' : 'cell-tag-missing'; } const icon = exists ? '' : ''; const title = exists ? rt : 'Объект/группа удалены'; return `${icon}${escHtml(label)}`; }).join(''); }, _renderSvcList(svcList, sgList) { const parts = []; (svcList || []).forEach(key => { const svc = State.services[key]; const exists = !!svc; const label = exists ? `${key} (${svc.proto}:${svc.dport})` : key; const cls = exists ? 'cell-tag-svc' : 'cell-tag-missing'; const icon = exists ? '' : ''; parts.push(`${icon}${escHtml(label)}`); }); (sgList || []).forEach(key => { const exists = !!State.service_groups[key]; const cls = exists ? 'cell-tag-sgr' : 'cell-tag-missing'; const icon = exists ? '' : ''; parts.push(`${icon}[${escHtml(key)}]`); }); return parts.join(''); }, // ─── Drag & Drop с пересчётом order ─────────────────────────────────────── _initDragDrop() { const tbody = document.getElementById('rules-tbody'); let dragIdx = null; tbody.querySelectorAll('tr[data-idx]').forEach(row => { row.addEventListener('dragstart', e => { dragIdx = parseInt(row.dataset.idx); row.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; }); row.addEventListener('dragend', () => { row.classList.remove('dragging'); tbody.querySelectorAll('tr').forEach(r => r.classList.remove('drag-over')); }); row.addEventListener('dragover', e => { e.preventDefault(); tbody.querySelectorAll('tr').forEach(r => r.classList.remove('drag-over')); row.classList.add('drag-over'); }); row.addEventListener('drop', async e => { e.preventDefault(); const targetIdx = parseInt(row.dataset.idx); if (dragIdx === null || dragIdx === targetIdx) return; // Перемещаем элемент const newRules = [...State.rules]; const [moved] = newRules.splice(dragIdx, 1); newRules.splice(targetIdx, 0, moved); // Пересчитываем order для всех правил const recalculated = this._recalcOrders(newRules); // Сохраняем через reorder + обновление каждого правила // Используем bulk-сохранение: сначала reorder, потом обновляем order const r = await api('POST', '/api/rules/reorder', { order: newRules.map((_, i) => i) }); if (r.ok) { // Обновляем order для каждого правила for (let i = 0; i < recalculated.length; i++) { await api('PUT', `/api/rules/${i}`, recalculated[i]); } await loadAll(); this.render(); } dragIdx = null; }); }); }, // ─── Модал правила ───────────────────────────────────────────────────────── openModal(idx) { hideError('rule-error'); const isEdit = idx !== undefined; document.getElementById('modal-rule-title').textContent = isEdit ? 'Редактировать правило' : 'Новое правило'; document.getElementById('rule-edit-idx').value = isEdit ? idx : ''; this._srcSelected = []; this._dstSelected = []; this._svcSelected = []; if (isEdit) { const rule = State.rules[idx]; document.getElementById('rule-name').value = rule.name || ''; document.getElementById('rule-order').value = rule.order || ''; document.getElementById('rule-action').value = rule.action || 'allow'; document.getElementById('rule-log').value = rule.log || 'false'; document.getElementById('rule-idp').value = rule.idp || 'false'; document.getElementById('rule-description').value = rule.description || ''; document.getElementById('rule-affinity').value = affinityStr(rule.affinity); this._srcSelected = [...(rule.src_list || [])]; this._dstSelected = [...(rule.dst_list || [])]; // Сервисы (rule.service_list || []).forEach(k => this._svcSelected.push({ ref_type: 'svc', ref_key: k })); (rule.service_group_list || []).forEach(k => this._svcSelected.push({ ref_type: 'sg', ref_key: k })); } else { document.getElementById('form-rule').reset(); // Предлагаем следующий order const maxOrder = State.rules.reduce((m, r) => Math.max(m, r.order || 0), 0); document.getElementById('rule-order').value = maxOrder + 10; } document.getElementById('rule-src-search').value = ''; document.getElementById('rule-dst-search').value = ''; document.getElementById('rule-svc-search').value = ''; this.renderSrcPicker(); this.renderDstPicker(); this.renderSvcPicker(); this.modal.show(); }, // ─── Пикер источника ─────────────────────────────────────────────────────── filterSrc(q) { this.renderSrcPicker(q); }, renderSrcPicker(q) { if (q === undefined) q = document.getElementById('rule-src-search').value || ''; q = q.toLowerCase(); const selectedKeys = new Set(this._srcSelected.map(i => `${i.ref_type}:${i.ref_key}`)); const avail = document.getElementById('rule-src-available'); const items = this._buildObjectGroupItems(q, selectedKeys, 'src'); avail.innerHTML = items || '
Нет объектов
'; const sel = document.getElementById('rule-src-selected'); sel.innerHTML = this._renderSelectedRefs(this._srcSelected, 'src') || '
Ничего не выбрано
'; document.getElementById('rule-src-count').textContent = this._srcSelected.length; }, // ─── Пикер назначения ────────────────────────────────────────────────────── filterDst(q) { this.renderDstPicker(q); }, renderDstPicker(q) { if (q === undefined) q = document.getElementById('rule-dst-search').value || ''; q = q.toLowerCase(); const selectedKeys = new Set(this._dstSelected.map(i => `${i.ref_type}:${i.ref_key}`)); const avail = document.getElementById('rule-dst-available'); avail.innerHTML = this._buildObjectGroupItems(q, selectedKeys, 'dst') || '
Нет объектов
'; const sel = document.getElementById('rule-dst-selected'); sel.innerHTML = this._renderSelectedRefs(this._dstSelected, 'dst') || '
Ничего не выбрано
'; document.getElementById('rule-dst-count').textContent = this._dstSelected.length; }, // ─── Пикер сервисов ──────────────────────────────────────────────────────── filterSvc(q) { this.renderSvcPicker(q); }, renderSvcPicker(q) { if (q === undefined) q = document.getElementById('rule-svc-search').value || ''; q = q.toLowerCase(); const selectedKeys = new Set(this._svcSelected.map(i => `${i.ref_type}:${i.ref_key}`)); const avail = document.getElementById('rule-svc-available'); const items = []; // Сервисы for (const [key, svc] of Object.entries(State.services)) { if (selectedKeys.has(`svc:${key}`)) continue; if (q && !key.toLowerCase().includes(q) && !(svc.name || '').toLowerCase().includes(q) && !(svc.dport || '').includes(q)) continue; items.push(`
${escHtml(key)} ${escHtml(svc.proto)} ${escHtml(svc.dport)}
`); } // Группы сервисов for (const [key, sg] of Object.entries(State.service_groups)) { if (selectedKeys.has(`sg:${key}`)) continue; if (q && !key.toLowerCase().includes(q) && !(sg.name || '').toLowerCase().includes(q)) continue; items.push(`
[${escHtml(key)}] group
`); } avail.innerHTML = items.join('') || '
Нет сервисов
'; const sel = document.getElementById('rule-svc-selected'); const selItems = this._svcSelected.map((item, idx) => { const isSg = item.ref_type === 'sg'; const label = isSg ? `[${item.ref_key}]` : item.ref_key; const exists = isSg ? !!State.service_groups[item.ref_key] : !!State.services[item.ref_key]; const cls = exists ? '' : 'picker-item-missing'; const badge = isSg ? `group` : `${escHtml((State.services[item.ref_key] || {}).proto || '')}`; return `
${exists ? '' : ''}${escHtml(label)} ${badge}
`; }); sel.innerHTML = selItems.join('') || '
Ничего не выбрано
'; document.getElementById('rule-svc-count').textContent = this._svcSelected.length; }, // ─── Вспомогательные методы пикеров ─────────────────────────────────────── _buildObjectGroupItems(q, selectedKeys, side) { const items = []; // Группы объектов for (const [key, grp] of Object.entries(State.groups)) { if (selectedKeys.has(`group:${key}`)) continue; if (q && !key.toLowerCase().includes(q) && !(grp.name || '').toLowerCase().includes(q)) continue; items.push(`
${escHtml(key)} group
`); } // Серверы for (const [key, obj] of Object.entries(State.objects)) { if (obj.type !== 'host') continue; if (selectedKeys.has(`server:${key}`)) continue; if (q && !key.toLowerCase().includes(q) && !(obj.ip || '').includes(q) && !(obj.description || '').toLowerCase().includes(q)) continue; items.push(`
${escHtml(key)} ${escHtml(obj.ip)} host
`); } // Сети for (const [key, obj] of Object.entries(State.objects)) { if (obj.type !== 'network') continue; if (selectedKeys.has(`net:${key}`)) continue; if (q && !key.toLowerCase().includes(q) && !(obj.ip || '').includes(q) && !(obj.description || '').toLowerCase().includes(q)) continue; items.push(`
${escHtml(key)} ${escHtml(obj.ip)}/${escHtml(obj.prefix)} net
`); } return items.join(''); }, _renderSelectedRefs(list, side) { return list.map((item, idx) => { const rt = item.ref_type; const rk = item.ref_key; let badge = ''; let label = rk; let exists = true; if (rt === 'group') { exists = !!State.groups[rk]; badge = `group`; } else if (rt === 'server') { exists = !!State.objects[rk]; badge = `host`; const o = State.objects[rk]; if (o) label += ` (${o.ip})`; } else if (rt === 'net') { exists = !!State.objects[rk]; badge = `net`; const o = State.objects[rk]; if (o) label += ` (${o.ip}/${o.prefix})`; } const cls = exists ? '' : 'picker-item-missing'; return `
${exists ? '' : ''}${escHtml(label)} ${badge}
`; }).join(''); }, addRef(side, refType, refKey) { const list = side === 'src' ? this._srcSelected : this._dstSelected; if (!list.find(i => i.ref_type === refType && i.ref_key === refKey)) { list.push({ ref_type: refType, ref_key: refKey }); } if (side === 'src') this.renderSrcPicker(); else this.renderDstPicker(); }, removeRef(side, idx) { const list = side === 'src' ? this._srcSelected : this._dstSelected; list.splice(idx, 1); if (side === 'src') this.renderSrcPicker(); else this.renderDstPicker(); }, addSvc(refType, refKey) { if (!this._svcSelected.find(i => i.ref_type === refType && i.ref_key === refKey)) { this._svcSelected.push({ ref_type: refType, ref_key: refKey }); } this.renderSvcPicker(); }, removeSvc(idx) { this._svcSelected.splice(idx, 1); this.renderSvcPicker(); }, // ─── Сохранение правила ──────────────────────────────────────────────────── async save() { hideError('rule-error'); const editIdx = document.getElementById('rule-edit-idx').value; const name = document.getElementById('rule-name').value.trim(); if (!name) { showError('rule-error', 'Имя правила обязательно'); return; } const svcList = this._svcSelected.filter(i => i.ref_type === 'svc').map(i => i.ref_key); const sgList = this._svcSelected.filter(i => i.ref_type === 'sg').map(i => i.ref_key); const rule = { name, order: parseInt(document.getElementById('rule-order').value) || 0, type: 'rule', description: document.getElementById('rule-description').value.trim(), action: document.getElementById('rule-action').value, log: document.getElementById('rule-log').value, idp: document.getElementById('rule-idp').value, affinity: parseAffinity(document.getElementById('rule-affinity').value), src_list: this._srcSelected, dst_list: this._dstSelected, service_list: svcList, service_group_list: sgList, }; let r; if (editIdx !== '') { r = await api('PUT', `/api/rules/${editIdx}`, rule); } else { r = await api('POST', '/api/rules', rule); } if (r.ok) { showToast(editIdx !== '' ? 'Правило обновлено' : 'Правило создано'); this.modal.hide(); await loadAll(); this.render(); } else { showError('rule-error', r.error || 'Ошибка'); } }, // ─── Разделитель (span) ──────────────────────────────────────────────────── openSpanModal(idx) { document.getElementById('span-edit-idx').value = idx !== undefined ? idx : ''; if (idx !== undefined) { const rule = State.rules[idx]; document.getElementById('span-name').value = rule.name || ''; document.getElementById('span-order').value = rule.order || ''; document.getElementById('span-affinity').value = affinityStr(rule.affinity); } else { document.getElementById('span-name').value = ''; const maxOrder = State.rules.reduce((m, r) => Math.max(m, r.order || 0), 0); document.getElementById('span-order').value = maxOrder + 10; document.getElementById('span-affinity').value = ''; } this.spanModal.show(); }, async saveSpan() { const editIdx = document.getElementById('span-edit-idx').value; const rule = { name: document.getElementById('span-name').value.trim(), order: parseInt(document.getElementById('span-order').value) || 0, type: 'span', affinity: parseAffinity(document.getElementById('span-affinity').value), }; let r; if (editIdx !== '') { r = await api('PUT', `/api/rules/${editIdx}`, rule); } else { r = await api('POST', '/api/rules', rule); } if (r.ok) { showToast('Разделитель сохранён'); this.spanModal.hide(); await loadAll(); this.render(); } else { showToast('Ошибка: ' + (r.error || ''), 'danger'); } }, async delete(idx) { const r = await api('DELETE', `/api/rules/${idx}`); if (r.ok) { showToast('Удалено', 'warning'); await loadAll(); this.render(); } } }; // ─── Инициализация ──────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', async () => { Objects.init(); Groups.init(); Services.init(); SvcGroups.init(); Rules.init(); await loadAll(); renderAll(); // Перерисовка при переключении вкладок document.querySelectorAll('[data-bs-toggle="tab"]').forEach(tab => { tab.addEventListener('shown.bs.tab', e => { const target = e.target.getAttribute('href'); if (target === '#tab-objects') Objects.render(); else if (target === '#tab-groups') Groups.render(); else if (target === '#tab-services') Services.render(); else if (target === '#tab-svcgroups') SvcGroups.render(); else if (target === '#tab-rules') Rules.render(); }); }); });