fw_rules_builder/app/static/app.js

1170 lines
50 KiB
JavaScript
Raw Permalink 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.

/**
* 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', `
<div id="${id}" class="toast align-items-center text-white ${bg} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">${msg}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>`);
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ─── Загрузка всех данных ─────────────────────────────────────────────────────
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'
? `<span class="badge-network">network</span>`
: `<span class="badge-host">host</span>`;
rows.push(`<tr>
<td><code>${escHtml(key)}</code></td>
<td>${typeBadge}</td>
<td>${escHtml(obj.ip)}</td>
<td>${escHtml(obj.prefix)}</td>
<td>${escHtml(obj.gw)}</td>
<td>${escHtml(obj.domain)}</td>
<td>${escHtml(obj.description)}</td>
<td><small>${escHtml(affinityStr(obj.affinity))}</small></td>
<td>
<button class="btn btn-sm btn-outline-secondary btn-action me-1" onclick="Objects.openModal('${escHtml(key)}')"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger btn-action" onclick="Objects.delete('${escHtml(key)}')"><i class="bi bi-trash"></i></button>
</td>
</tr>`);
}
tbody.innerHTML = rows.join('') || '<tr><td colspan="9" class="text-center text-muted">Нет объектов</td></tr>';
},
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 ? '' : '<i class="bi bi-exclamation-triangle-fill me-1"></i>';
return `<span class="cell-tag ${cls}" title="${exists ? '' : 'Объект удалён'}">${icon}${escHtml(i.hostname)}</span>`;
}).join(' ');
rows.push(`<tr>
<td><code>${escHtml(key)}</code></td>
<td>${escHtml(grp.name)}</td>
<td><div class="cell-tags">${items || '<span class="text-muted">—</span>'}</div></td>
<td>
<button class="btn btn-sm btn-outline-secondary btn-action me-1" onclick="Groups.openModal('${escHtml(key)}')"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger btn-action" onclick="Groups.delete('${escHtml(key)}')"><i class="bi bi-trash"></i></button>
</td>
</tr>`);
}
tbody.innerHTML = rows.join('') || '<tr><td colspan="4" class="text-center text-muted">Нет групп</td></tr>';
},
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' ? `<span class="badge-network item-badge">net</span>` : `<span class="badge-host item-badge">host</span>`;
availItems.push(`<div class="picker-item" onclick="Groups.addItem('${escHtml(key)}')">
<span class="item-label" title="${escHtml(obj.description)}">${escHtml(key)}</span>
${badge}
<span class="item-action"><i class="bi bi-plus-circle"></i></span>
</div>`);
}
avail.innerHTML = availItems.join('') || '<div class="text-muted small p-2">Нет объектов</div>';
// Выбранные
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' ? `<span class="badge-network item-badge">net</span>` : `<span class="badge-host item-badge">host</span>`) : `<span class="item-badge badge bg-danger">удалён</span>`;
const cls = obj ? '' : 'picker-item-missing';
return `<div class="picker-item picker-item-selected ${cls}">
<span class="item-label">${obj ? '' : '<i class="bi bi-exclamation-triangle-fill text-danger me-1"></i>'}${escHtml(item.hostname)}</span>
${badge}
<span class="item-action" onclick="Groups.removeItem(${idx})"><i class="bi bi-x-circle"></i></span>
</div>`;
});
sel.innerHTML = selItems.join('') || '<div class="text-muted small p-2">Ничего не выбрано</div>';
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 = `<span class="badge bg-secondary">${escHtml(svc.proto)}</span>`;
rows.push(`<tr>
<td><code>${escHtml(key)}</code></td>
<td>${escHtml(svc.name)}</td>
<td>${protoBadge}</td>
<td>${escHtml(svc.sport)}</td>
<td>${escHtml(svc.dport)}</td>
<td>
<button class="btn btn-sm btn-outline-secondary btn-action me-1" onclick="Services.openModal('${escHtml(key)}')"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger btn-action" onclick="Services.delete('${escHtml(key)}')"><i class="bi bi-trash"></i></button>
</td>
</tr>`);
}
tbody.innerHTML = rows.join('') || '<tr><td colspan="6" class="text-center text-muted">Нет сервисов</td></tr>';
},
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 ? '' : '<i class="bi bi-exclamation-triangle-fill me-1"></i>';
return `<span class="cell-tag ${cls}" title="${exists ? '' : 'Сервис удалён'}">${icon}${escHtml(label)}</span>`;
}).join(' ');
rows.push(`<tr>
<td><code>${escHtml(key)}</code></td>
<td>${escHtml(sg.name)}</td>
<td><div class="cell-tags">${items || '<span class="text-muted">—</span>'}</div></td>
<td>
<button class="btn btn-sm btn-outline-secondary btn-action me-1" onclick="SvcGroups.openModal('${escHtml(key)}')"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger btn-action" onclick="SvcGroups.delete('${escHtml(key)}')"><i class="bi bi-trash"></i></button>
</td>
</tr>`);
}
tbody.innerHTML = rows.join('') || '<tr><td colspan="4" class="text-center text-muted">Нет групп сервисов</td></tr>';
},
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(`<div class="picker-item" onclick="SvcGroups.addItem('${escHtml(key)}')">
<span class="item-label" title="${escHtml(svc.name)}">${escHtml(key)}</span>
<span class="item-badge badge bg-secondary">${escHtml(svc.proto)}</span>
<span class="item-badge text-muted">${escHtml(svc.dport)}</span>
<span class="item-action"><i class="bi bi-plus-circle"></i></span>
</div>`);
}
avail.innerHTML = availItems.join('') || '<div class="text-muted small p-2">Нет сервисов</div>';
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 `<div class="picker-item picker-item-selected ${cls}">
<span class="item-label">${exists ? '' : '<i class="bi bi-exclamation-triangle-fill text-danger me-1"></i>'}${escHtml(label)}</span>
${exists ? '' : '<span class="item-badge badge bg-danger">удалён</span>'}
<span class="item-action" onclick="SvcGroups.removeItem(${idx})"><i class="bi bi-x-circle"></i></span>
</div>`;
});
sel.innerHTML = selItems.join('') || '<div class="text-muted small p-2">Ничего не выбрано</div>';
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(`<tr class="rule-span" draggable="true" data-idx="${idx}">
<td colspan="10"><i class="bi bi-grip-vertical drag-handle me-2"></i>${escHtml(rule.name)}</td>
<td><small class="text-muted">${escHtml(affinityStr(rule.affinity))}</small></td>
<td>
<button class="btn btn-sm btn-outline-secondary btn-action me-1" onclick="Rules.openSpanModal(${idx})"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger btn-action" onclick="Rules.delete(${idx})"><i class="bi bi-trash"></i></button>
</td>
</tr>`);
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 = `<span class="badge-action-${action}">${escHtml(action)}</span>`;
rows.push(`<tr class="${rowClass}" draggable="true" data-idx="${idx}">
<td class="text-center text-muted">${ruleNum}</td>
<td class="text-center">${escHtml(rule.order || '')}</td>
<td><strong>${escHtml(rule.name)}</strong></td>
<td><small>${escHtml(rule.description || '')}</small></td>
<td><div class="cell-tags">${srcTags}</div></td>
<td><div class="cell-tags">${dstTags}</div></td>
<td><div class="cell-tags">${svcTags}</div></td>
<td>${actionBadge}</td>
<td class="text-center"><small>${escHtml(rule.log || '')}</small></td>
<td class="text-center"><small>${escHtml(rule.idp || '')}</small></td>
<td><small>${escHtml(affinityStr(rule.affinity))}</small></td>
<td>
<button class="btn btn-sm btn-outline-secondary btn-action me-1" onclick="Rules.openModal(${idx})"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger btn-action" onclick="Rules.delete(${idx})"><i class="bi bi-trash"></i></button>
</td>
</tr>`);
});
tbody.innerHTML = rows.join('') || '<tr><td colspan="12" class="text-center text-muted">Нет правил</td></tr>';
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 ? '' : '<i class="bi bi-exclamation-triangle-fill me-1"></i>';
const title = exists ? rt : 'Объект/группа удалены';
return `<span class="cell-tag ${cls}" title="${escHtml(title)}">${icon}${escHtml(label)}</span>`;
}).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 ? '' : '<i class="bi bi-exclamation-triangle-fill me-1"></i>';
parts.push(`<span class="cell-tag ${cls}" title="${exists ? '' : 'Сервис удалён'}">${icon}${escHtml(label)}</span>`);
});
(sgList || []).forEach(key => {
const exists = !!State.service_groups[key];
const cls = exists ? 'cell-tag-sgr' : 'cell-tag-missing';
const icon = exists ? '' : '<i class="bi bi-exclamation-triangle-fill me-1"></i>';
parts.push(`<span class="cell-tag ${cls}" title="${exists ? '' : 'Группа сервисов удалена'}">${icon}[${escHtml(key)}]</span>`);
});
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 || '<div class="text-muted small p-2">Нет объектов</div>';
const sel = document.getElementById('rule-src-selected');
sel.innerHTML = this._renderSelectedRefs(this._srcSelected, 'src') || '<div class="text-muted small p-2">Ничего не выбрано</div>';
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') || '<div class="text-muted small p-2">Нет объектов</div>';
const sel = document.getElementById('rule-dst-selected');
sel.innerHTML = this._renderSelectedRefs(this._dstSelected, 'dst') || '<div class="text-muted small p-2">Ничего не выбрано</div>';
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(`<div class="picker-item" onclick="Rules.addSvc('svc','${escHtml(key)}')">
<span class="item-label">${escHtml(key)}</span>
<span class="item-badge badge bg-secondary">${escHtml(svc.proto)}</span>
<span class="item-badge text-muted">${escHtml(svc.dport)}</span>
<span class="item-action"><i class="bi bi-plus-circle"></i></span>
</div>`);
}
// Группы сервисов
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(`<div class="picker-item" onclick="Rules.addSvc('sg','${escHtml(key)}')">
<span class="item-label">[${escHtml(key)}]</span>
<span class="item-badge badge" style="background:#6f42c1;color:white">group</span>
<span class="item-action"><i class="bi bi-plus-circle"></i></span>
</div>`);
}
avail.innerHTML = items.join('') || '<div class="text-muted small p-2">Нет сервисов</div>';
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
? `<span class="item-badge badge" style="background:#6f42c1;color:white;font-size:10px">group</span>`
: `<span class="item-badge badge bg-secondary">${escHtml((State.services[item.ref_key] || {}).proto || '')}</span>`;
return `<div class="picker-item picker-item-selected ${cls}">
<span class="item-label">${exists ? '' : '<i class="bi bi-exclamation-triangle-fill text-danger me-1"></i>'}${escHtml(label)}</span>
${badge}
<span class="item-action" onclick="Rules.removeSvc(${idx})"><i class="bi bi-x-circle"></i></span>
</div>`;
});
sel.innerHTML = selItems.join('') || '<div class="text-muted small p-2">Ничего не выбрано</div>';
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(`<div class="picker-item" onclick="Rules.addRef('${side}','group','${escHtml(key)}')">
<span class="item-label">${escHtml(key)}</span>
<span class="item-badge badge" style="background:#0c5460;color:white;font-size:10px">group</span>
<span class="item-action"><i class="bi bi-plus-circle"></i></span>
</div>`);
}
// Серверы
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(`<div class="picker-item" onclick="Rules.addRef('${side}','server','${escHtml(key)}')">
<span class="item-label" title="${escHtml(obj.description)}">${escHtml(key)} <small class="text-muted">${escHtml(obj.ip)}</small></span>
<span class="item-badge badge-host">host</span>
<span class="item-action"><i class="bi bi-plus-circle"></i></span>
</div>`);
}
// Сети
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(`<div class="picker-item" onclick="Rules.addRef('${side}','net','${escHtml(key)}')">
<span class="item-label" title="${escHtml(obj.description)}">${escHtml(key)} <small class="text-muted">${escHtml(obj.ip)}/${escHtml(obj.prefix)}</small></span>
<span class="item-badge badge-network">net</span>
<span class="item-action"><i class="bi bi-plus-circle"></i></span>
</div>`);
}
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 = `<span class="item-badge badge" style="background:#0c5460;color:white;font-size:10px">group</span>`;
} else if (rt === 'server') {
exists = !!State.objects[rk];
badge = `<span class="item-badge badge-host">host</span>`;
const o = State.objects[rk];
if (o) label += ` (${o.ip})`;
} else if (rt === 'net') {
exists = !!State.objects[rk];
badge = `<span class="item-badge badge-network">net</span>`;
const o = State.objects[rk];
if (o) label += ` (${o.ip}/${o.prefix})`;
}
const cls = exists ? '' : 'picker-item-missing';
return `<div class="picker-item picker-item-selected ${cls}">
<span class="item-label">${exists ? '' : '<i class="bi bi-exclamation-triangle-fill text-danger me-1"></i>'}${escHtml(label)}</span>
${badge}
<span class="item-action" onclick="Rules.removeRef('${side}',${idx})"><i class="bi bi-x-circle"></i></span>
</div>`;
}).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();
});
});
});